Análisis y Predicción del Riesgo de Siniestro en Seguros de Automóviles
Un Estudio con Datos de Porto Seguro
Marc Román Porras
Máster Universitario en Ciencia de Datos
Universitat Oberta de Catalunya
Marc Román Porras
Máster Universitario en Ciencia de Datos
Universitat Oberta de Catalunya
1. Carga y Preparación Inicial del Dataset
Importación de librerías
# Paquetería necesaria:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import plotly.subplots as sp
import plotly.graph_objects as go
import seaborn as sns
import plotly.express as px
import math
import pickle
import optuna
import optuna.visualization.matplotlib as opt_viz
import subprocess
import time
from IPython.display import display, HTML
# Apertura del servidor guardado de Optuna:
import webbrowser
# Modelización:
from sklearn.ensemble import RandomForestClassifier
from sklearn.inspection import permutation_importance,PartialDependenceDisplay
from sklearn.preprocessing import StandardScaler, QuantileTransformer,OneHotEncoder
from sklearn.model_selection import train_test_split,StratifiedKFold
from sklearn.impute import SimpleImputer
from sklearn.metrics import (
roc_auc_score,
average_precision_score,
recall_score,
f1_score,
precision_score,
accuracy_score,
brier_score_loss,
confusion_matrix,
ConfusionMatrixDisplay,
auc,
precision_recall_curve
)
from sklearn.calibration import CalibratedClassifierCV, calibration_curve
from xgboost import XGBClassifier
import xgboost as xgb
from lightgbm import LGBMClassifier
from sklearn.linear_model import LogisticRegression
from pytorch_tabnet.tab_model import TabNetClassifier
from statsmodels.stats.outliers_influence import variance_inflation_factor
import scipy.stats as stats
from scipy.stats import ks_2samp
from skimage.filters import threshold_otsu
# XAI
import shap
import lime
import lime.lime_tabular
import os
# Paleta de colores basada en azules para unificar la visualización de datos:
custom_palette = ["#003f5c", "#2f4b7c", "#665191", "#a05195", "#d45087", "#f95d6a"]
sns.set_palette(custom_palette)
c:\Program Files\Python311\Lib\site-packages\tqdm\auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html from .autonotebook import tqdm as notebook_tqdm
Carga de datos y estructura inicial
# Carga de los conjuntos de datos de train y test, respectivamente desde la fuente de datos orígen (local):
train = pd.read_csv("./train.csv")
test = pd.read_csv("./test.csv")
# Instancia de visualización raw tras la carga:
print(f"Dimensiones del dataset de entrenamiento: {train.shape}")
print(f"Dimensiones del dataset de test: {test.shape}")
# Muestro las primeras filas
train.head()
Dimensiones del dataset de entrenamiento: (595212, 59) Dimensiones del dataset de test: (892816, 58)
| id | target | ps_ind_01 | ps_ind_02_cat | ps_ind_03 | ps_ind_04_cat | ps_ind_05_cat | ps_ind_06_bin | ps_ind_07_bin | ps_ind_08_bin | ... | ps_calc_11 | ps_calc_12 | ps_calc_13 | ps_calc_14 | ps_calc_15_bin | ps_calc_16_bin | ps_calc_17_bin | ps_calc_18_bin | ps_calc_19_bin | ps_calc_20_bin | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 7 | 0 | 2 | 2 | 5 | 1 | 0 | 0 | 1 | 0 | ... | 9 | 1 | 5 | 8 | 0 | 1 | 1 | 0 | 0 | 1 |
| 1 | 9 | 0 | 1 | 1 | 7 | 0 | 0 | 0 | 0 | 1 | ... | 3 | 1 | 1 | 9 | 0 | 1 | 1 | 0 | 1 | 0 |
| 2 | 13 | 0 | 5 | 4 | 9 | 1 | 0 | 0 | 0 | 1 | ... | 4 | 2 | 7 | 7 | 0 | 1 | 1 | 0 | 1 | 0 |
| 3 | 16 | 0 | 0 | 1 | 2 | 0 | 0 | 1 | 0 | 0 | ... | 2 | 2 | 4 | 9 | 0 | 0 | 0 | 0 | 0 | 0 |
| 4 | 17 | 0 | 0 | 2 | 0 | 1 | 0 | 1 | 0 | 0 | ... | 3 | 1 | 1 | 3 | 0 | 0 | 0 | 1 | 1 | 0 |
5 rows × 59 columns
# Revisión de datos inicial:
train.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 595212 entries, 0 to 595211 Data columns (total 59 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 id 595212 non-null int64 1 target 595212 non-null int64 2 ps_ind_01 595212 non-null int64 3 ps_ind_02_cat 595212 non-null int64 4 ps_ind_03 595212 non-null int64 5 ps_ind_04_cat 595212 non-null int64 6 ps_ind_05_cat 595212 non-null int64 7 ps_ind_06_bin 595212 non-null int64 8 ps_ind_07_bin 595212 non-null int64 9 ps_ind_08_bin 595212 non-null int64 10 ps_ind_09_bin 595212 non-null int64 11 ps_ind_10_bin 595212 non-null int64 12 ps_ind_11_bin 595212 non-null int64 13 ps_ind_12_bin 595212 non-null int64 14 ps_ind_13_bin 595212 non-null int64 15 ps_ind_14 595212 non-null int64 16 ps_ind_15 595212 non-null int64 17 ps_ind_16_bin 595212 non-null int64 18 ps_ind_17_bin 595212 non-null int64 19 ps_ind_18_bin 595212 non-null int64 20 ps_reg_01 595212 non-null float64 21 ps_reg_02 595212 non-null float64 22 ps_reg_03 595212 non-null float64 23 ps_car_01_cat 595212 non-null int64 24 ps_car_02_cat 595212 non-null int64 25 ps_car_03_cat 595212 non-null int64 26 ps_car_04_cat 595212 non-null int64 27 ps_car_05_cat 595212 non-null int64 28 ps_car_06_cat 595212 non-null int64 29 ps_car_07_cat 595212 non-null int64 30 ps_car_08_cat 595212 non-null int64 31 ps_car_09_cat 595212 non-null int64 32 ps_car_10_cat 595212 non-null int64 33 ps_car_11_cat 595212 non-null int64 34 ps_car_11 595212 non-null int64 35 ps_car_12 595212 non-null float64 36 ps_car_13 595212 non-null float64 37 ps_car_14 595212 non-null float64 38 ps_car_15 595212 non-null float64 39 ps_calc_01 595212 non-null float64 40 ps_calc_02 595212 non-null float64 41 ps_calc_03 595212 non-null float64 42 ps_calc_04 595212 non-null int64 43 ps_calc_05 595212 non-null int64 44 ps_calc_06 595212 non-null int64 45 ps_calc_07 595212 non-null int64 46 ps_calc_08 595212 non-null int64 47 ps_calc_09 595212 non-null int64 48 ps_calc_10 595212 non-null int64 49 ps_calc_11 595212 non-null int64 50 ps_calc_12 595212 non-null int64 51 ps_calc_13 595212 non-null int64 52 ps_calc_14 595212 non-null int64 53 ps_calc_15_bin 595212 non-null int64 54 ps_calc_16_bin 595212 non-null int64 55 ps_calc_17_bin 595212 non-null int64 56 ps_calc_18_bin 595212 non-null int64 57 ps_calc_19_bin 595212 non-null int64 58 ps_calc_20_bin 595212 non-null int64 dtypes: float64(10), int64(49) memory usage: 267.9 MB
El dataset, según podemos ver con la instancia anterior, presenta únicamente dos tipos de datos a nivel estructural: int64 y float64. A pesar de esta simplicidad aparente, la descripción proporcionada por el propio conjunto de datos (fuente: Kaggle) indica que las variables pueden clasificarse en tres categorías, deducibles por sufijos:
_bin: Variables binarias_cat: Variables categóricas multinivel- Sin sufijo: Se interpretan como cuantitativas (continuas o discretas)
Estas tres categorías, pueden ser inducidas a simple vista a partir de los sujifos del nombre de cada variable (ind, reg, calc, etc.), que dan una pista contextual de su tipología. No obstante, no se dispone de una descripción funcional clara de cada campo más allá que su nombre y lo que se extraerá a partir del análisis descriptivo. La naturaleza del dataset es por tanto ofuscada; no podemos tener consciencia situacional del contenido funcional de las variables, más allá de nuestro objetivo y de las especificidades que podamos inducir de su análisis estadístico/descriptivo que se llevará a cabo en los próximos apartados. A efectos técnicos, la ofuscación de un conjunto de datos de una aseguradora y/o de cualquier entidad financiera, permite presisamente poner a disposición su uso para casos de uso abiertos, como el presente en este trabajo, sin tener problemas de integridad en lo relativo a la privacidad y seguridad del dato, al contener este elementos privados del contrato de cada cliente con la entidad aseguradora, o elementos idiosincráticos tales como su salario, ingresos otros, etc... En definitiva, tenemos un conjunto de datos que, si bien tenemos el contexto del sector asegurador, este no se requiere de manera exhaustiva pues el enfoque osfuscado, limita las consideraciones puras del negocio que puedam derivarse de las variables, ya que no conocemos a qué refieren.
Una necesidad primeriza es el establecimiento/designación de la tipología de cada variable que tenemos, de cara a capacitarnos una tipología de análisis u otra. Por ello, se propone un enfoque híbrido para designar qué tipología de variables tenemos y cómo deben ser interpretadas, más allá del nombre. Se aplicará una clasificación auxiliar según el número de niveles únicos de cada variable. Esta clasificación, se apoyará en un umbral (threshold) de 20 valores únicos, permitiendo la siguiente interpretación:
- Variables con ≤ 20 niveles únicos: consideradas categóricas
- Variables con > 20 niveles únicos: consideradas cuantitativas continuas
Aunque este umbral pueda parecer bajo para algunas variables continuas, se justifica empíricamente por la naturaleza del conjunto de datos, y permite una distinción pragmática y operativa en el análisis posterior.
# Resumen de la totalidad de las variables:
def generar_resumen(df, threshold_cual, solo_cuantitativas=False):
"""
Genera una tabla resumen de las variables en un DataFrame,
clasificándolas por tipo y ordenándolas en consecuencia.
Parámetros:
- df: DataFrame de entrada.
- threshold_cual: Umbral para considerar una variable como cualitativa o continua.
- solo_cuantitativas: Si es True, filtra solo las variables cuantitativas (continua o cont/cual).
Retorno:
- Un DataFrame con la información formateada.
"""
# Elimino la columna 'id' si está presente
temp_df = df.drop(columns=["id"], errors="ignore")
summary = pd.DataFrame(temp_df.dtypes, columns=["Data Type"])
summary["num_na"] = (temp_df == -1).sum().values
summary["num_unique"] = temp_df.nunique().values
summary["Category"] = None
summary["Variable Type"] = None
for col in temp_df.columns:
num_uniques = summary.loc[col, "num_unique"]
if "bin" in col or col == "target":
summary.loc[col, "Category"] = "Binaria"
elif "cat" in col:
summary.loc[col, "Category"] = "Cualitativa"
elif pd.api.types.is_numeric_dtype(temp_df[col]):
if np.issubdtype(temp_df[col].dtype, np.floating):
summary.loc[col, "Category"] = "Continua"
elif np.issubdtype(temp_df[col].dtype, np.integer):
summary.loc[col, "Category"] = "Cont/Cual"
else:
summary.loc[col, "Category"] = "Unknown"
# Asigno Variable Type basado en num_unique:
if num_uniques <= threshold_cual:
summary.loc[col, "Variable Type"] = "Cualitativa"
else:
summary.loc[col, "Variable Type"] = "Continua"
# Filtrado si solo se quieren cuantitativas para futura viz:
if solo_cuantitativas:
summary = summary[summary["Variable Type"].isin(["Continua"])]
category_order = {"Binaria": 1, "Cualitativa": 2, "Continua": 3, "Cont/Cual": 4}
summary["Category Order"] = summary["Category"].map(category_order)
summary = summary.sort_values(by="Category Order")
summary = summary.drop(columns=["Category Order"])
cmap = sns.light_palette("skyblue", as_cmap=True)
return summary.style.background_gradient(cmap=cmap)
# Ejecuto la tabla resumen con el dataset original y un threshold para considerar una variable como "contínua",
# cuando supera los 20 valores distintos sobre el conjunto total::
threshold_cual=20
generar_resumen(train,threshold_cual)
| Data Type | num_na | num_unique | Category | Variable Type | |
|---|---|---|---|---|---|
| target | int64 | 0 | 2 | Binaria | Cualitativa |
| ps_calc_18_bin | int64 | 0 | 2 | Binaria | Cualitativa |
| ps_calc_17_bin | int64 | 0 | 2 | Binaria | Cualitativa |
| ps_calc_16_bin | int64 | 0 | 2 | Binaria | Cualitativa |
| ps_calc_15_bin | int64 | 0 | 2 | Binaria | Cualitativa |
| ps_calc_19_bin | int64 | 0 | 2 | Binaria | Cualitativa |
| ps_ind_18_bin | int64 | 0 | 2 | Binaria | Cualitativa |
| ps_ind_17_bin | int64 | 0 | 2 | Binaria | Cualitativa |
| ps_ind_16_bin | int64 | 0 | 2 | Binaria | Cualitativa |
| ps_ind_13_bin | int64 | 0 | 2 | Binaria | Cualitativa |
| ps_ind_12_bin | int64 | 0 | 2 | Binaria | Cualitativa |
| ps_calc_20_bin | int64 | 0 | 2 | Binaria | Cualitativa |
| ps_ind_10_bin | int64 | 0 | 2 | Binaria | Cualitativa |
| ps_ind_09_bin | int64 | 0 | 2 | Binaria | Cualitativa |
| ps_ind_08_bin | int64 | 0 | 2 | Binaria | Cualitativa |
| ps_ind_07_bin | int64 | 0 | 2 | Binaria | Cualitativa |
| ps_ind_06_bin | int64 | 0 | 2 | Binaria | Cualitativa |
| ps_ind_11_bin | int64 | 0 | 2 | Binaria | Cualitativa |
| ps_ind_02_cat | int64 | 216 | 5 | Cualitativa | Cualitativa |
| ps_ind_04_cat | int64 | 83 | 3 | Cualitativa | Cualitativa |
| ps_car_11_cat | int64 | 0 | 104 | Cualitativa | Continua |
| ps_car_10_cat | int64 | 0 | 3 | Cualitativa | Cualitativa |
| ps_car_09_cat | int64 | 569 | 6 | Cualitativa | Cualitativa |
| ps_car_08_cat | int64 | 0 | 2 | Cualitativa | Cualitativa |
| ps_ind_05_cat | int64 | 5809 | 8 | Cualitativa | Cualitativa |
| ps_car_06_cat | int64 | 0 | 18 | Cualitativa | Cualitativa |
| ps_car_07_cat | int64 | 11489 | 3 | Cualitativa | Cualitativa |
| ps_car_04_cat | int64 | 0 | 10 | Cualitativa | Cualitativa |
| ps_car_03_cat | int64 | 411231 | 3 | Cualitativa | Cualitativa |
| ps_car_02_cat | int64 | 5 | 3 | Cualitativa | Cualitativa |
| ps_car_01_cat | int64 | 107 | 13 | Cualitativa | Cualitativa |
| ps_car_05_cat | int64 | 266551 | 3 | Cualitativa | Cualitativa |
| ps_calc_03 | float64 | 0 | 10 | Continua | Cualitativa |
| ps_calc_02 | float64 | 0 | 10 | Continua | Cualitativa |
| ps_calc_01 | float64 | 0 | 10 | Continua | Cualitativa |
| ps_car_15 | float64 | 0 | 15 | Continua | Cualitativa |
| ps_car_12 | float64 | 1 | 184 | Continua | Continua |
| ps_car_13 | float64 | 0 | 70482 | Continua | Continua |
| ps_reg_01 | float64 | 0 | 10 | Continua | Cualitativa |
| ps_reg_02 | float64 | 0 | 19 | Continua | Cualitativa |
| ps_reg_03 | float64 | 107772 | 5013 | Continua | Continua |
| ps_car_14 | float64 | 42620 | 850 | Continua | Continua |
| ps_ind_01 | int64 | 0 | 8 | Cont/Cual | Cualitativa |
| ps_ind_14 | int64 | 0 | 5 | Cont/Cual | Cualitativa |
| ps_ind_03 | int64 | 0 | 12 | Cont/Cual | Cualitativa |
| ps_ind_15 | int64 | 0 | 14 | Cont/Cual | Cualitativa |
| ps_calc_14 | int64 | 0 | 24 | Cont/Cual | Continua |
| ps_calc_13 | int64 | 0 | 14 | Cont/Cual | Cualitativa |
| ps_calc_12 | int64 | 0 | 11 | Cont/Cual | Cualitativa |
| ps_calc_10 | int64 | 0 | 26 | Cont/Cual | Continua |
| ps_calc_09 | int64 | 0 | 8 | Cont/Cual | Cualitativa |
| ps_calc_08 | int64 | 0 | 11 | Cont/Cual | Cualitativa |
| ps_calc_07 | int64 | 0 | 10 | Cont/Cual | Cualitativa |
| ps_calc_06 | int64 | 0 | 11 | Cont/Cual | Cualitativa |
| ps_calc_04 | int64 | 0 | 6 | Cont/Cual | Cualitativa |
| ps_car_11 | int64 | 5 | 5 | Cont/Cual | Cualitativa |
| ps_calc_11 | int64 | 0 | 20 | Cont/Cual | Cualitativa |
| ps_calc_05 | int64 | 0 | 7 | Cont/Cual | Cualitativa |
# Visualizo solo continuas asumidas:
generar_resumen(train,threshold_cual, True)
| Data Type | num_na | num_unique | Category | Variable Type | |
|---|---|---|---|---|---|
| ps_car_11_cat | int64 | 0 | 104 | Cualitativa | Continua |
| ps_reg_03 | float64 | 107772 | 5013 | Continua | Continua |
| ps_car_12 | float64 | 1 | 184 | Continua | Continua |
| ps_car_13 | float64 | 0 | 70482 | Continua | Continua |
| ps_car_14 | float64 | 42620 | 850 | Continua | Continua |
| ps_calc_10 | int64 | 0 | 26 | Cont/Cual | Continua |
| ps_calc_14 | int64 | 0 | 24 | Cont/Cual | Continua |
Del resultado, vemos 2 columnas relativas a la tipología: Category, referente a la categoría expresada por el nombre de la variable, y Variable Type, que es la tipología inducida por el criterio de 20 niveles que se ha decidido imponer. Más en detalle, del resultado PS_CALC_10 y PS_CALC_14, podrían ser las más problemáticas, pero assumimos un potencial error en la consideración del threshold.
# Segmentación/separación de variables en función del threshold anteriormente definido:
def classify_variables(df, target, threshold_cual=20):
"""
Clasifica las variables en binarias, categóricas o continuas, sin modificar el dataset.
Parámetros:
- df: DataFrame con los datos.
- target: Nombre de la variable objetivo (se excluye de la clasificación).
- threshold_cual: Número máximo de valores únicos para considerar una variable como categórica.
Retorno:
- binary_vars: Lista de variables binarias.
- categorical_vars: Lista de variables categóricas.
- continuous_vars: Lista de variables continuas.
"""
# Identificar la columna ID (suponiendo que es la primera columna)
id_column = df.columns[0]
binary_vars = []
categorical_vars = []
continuous_vars = []
for col in df.columns:
if col in [target, id_column]: # Excluir target e ID
continue
unique_values = df[col].nunique()
if unique_values == 2:
binary_vars.append(col)
elif unique_values <= threshold_cual:
categorical_vars.append(col)
else:
continuous_vars.append(col)
return binary_vars, categorical_vars, continuous_vars
# Aplicar la función al dataset train
target='target'
binary_vars, categorical_vars, continuous_vars = classify_variables(train, target)
# Muestra del resultado
print(f"Variables binarias: {binary_vars}\n")
print(f"Variables categóricas: {categorical_vars}\n")
print(f"Variables continuas: {continuous_vars}\n")
Variables binarias: ['ps_ind_06_bin', 'ps_ind_07_bin', 'ps_ind_08_bin', 'ps_ind_09_bin', 'ps_ind_10_bin', 'ps_ind_11_bin', 'ps_ind_12_bin', 'ps_ind_13_bin', 'ps_ind_16_bin', 'ps_ind_17_bin', 'ps_ind_18_bin', 'ps_car_08_cat', 'ps_calc_15_bin', 'ps_calc_16_bin', 'ps_calc_17_bin', 'ps_calc_18_bin', 'ps_calc_19_bin', 'ps_calc_20_bin'] Variables categóricas: ['ps_ind_01', 'ps_ind_02_cat', 'ps_ind_03', 'ps_ind_04_cat', 'ps_ind_05_cat', 'ps_ind_14', 'ps_ind_15', 'ps_reg_01', 'ps_reg_02', 'ps_car_01_cat', 'ps_car_02_cat', 'ps_car_03_cat', 'ps_car_04_cat', 'ps_car_05_cat', 'ps_car_06_cat', 'ps_car_07_cat', 'ps_car_09_cat', 'ps_car_10_cat', 'ps_car_11', 'ps_car_15', 'ps_calc_01', 'ps_calc_02', 'ps_calc_03', 'ps_calc_04', 'ps_calc_05', 'ps_calc_06', 'ps_calc_07', 'ps_calc_08', 'ps_calc_09', 'ps_calc_11', 'ps_calc_12', 'ps_calc_13'] Variables continuas: ['ps_reg_03', 'ps_car_11_cat', 'ps_car_12', 'ps_car_13', 'ps_car_14', 'ps_calc_10', 'ps_calc_14']
# Vsualización de la tipología a alto nivel en nuestro dataset:
df_types = pd.DataFrame({
"Variable": binary_vars + categorical_vars + continuous_vars,
"Tipo": ["Binaria"] * len(binary_vars) + ["Categorica"] * len(categorical_vars) + ["Continua"] * len(continuous_vars)
})
sns.set(style="whitegrid", palette="Blues")
plt.figure(figsize=(20, 5))
ax = sns.countplot(data=df_types, y="Tipo", order=["Binaria", "Categorica", "Continua"])
plt.title("Distribución de Tipos de Variables en el Dataset")
plt.xlabel("Cantidad de Variables")
plt.ylabel("Tipo de Variable")
plt.show()
2. Análisis exploratorio de datos (EDA)
Visualización del target
# Visualización del target:
target_counts = train["target"].value_counts().reset_index()
target_counts.columns = ["Clase", "Frecuencia"]
target_counts["Porcentaje"] = (target_counts["Frecuencia"] / target_counts["Frecuencia"].sum()) * 100
color_scale = ["#00274D", "#004488", "#0066CC", "#3399FF", "#66B2FF"]
fig = px.bar(target_counts, x="Clase", y="Frecuencia", text="Porcentaje",
title="Distribución de la Variable Objetivo",
labels={"Clase": "Clase", "Frecuencia": "Frecuencia"},
color="Frecuencia", color_continuous_scale=color_scale)
fig.update_traces(texttemplate='%{text:.2f}%', textposition='outside')
fig.update_layout(xaxis=dict(tickmode='array', tickvals=[0, 1], ticktext=['Clase 0', 'Clase 1']),
coloraxis_showscale=False,
template="plotly_white")
fig.show()
Del plot anterior concluimos acerca de:
- La variable objetivo está altamente desbalanceada. Solo el 3.64% de las observaciones pertenecen a la Clase 1 (evento de riesgo de siniestralidad), mientras que el 96.36% están en la Clase 0 (no evento).
- Este desbalance extremo puede afectar negativamente el rendimiento de los modelos de clasificación, especialmente en métricas como
accuracy, si se evalúa un modelo de manera tan generalista.Por ello, será necesario aplicar técnicas para manejar el desbalance (p. ej., ajustar
class_weight, modificar umbrales de decisión, o usar métricas como PR-AUC y F1-score para evaluación).
Análisis bivariante de variables vs target.
# Variables Categóricas vs Target en Grid:
num_vars = len(categorical_vars)
num_cols = 3
num_rows = (num_vars // num_cols) + (num_vars % num_cols > 0)
fig_cat_target = sp.make_subplots(rows=num_rows, cols=num_cols, subplot_titles=categorical_vars)
row, col = 1, 1
for var in categorical_vars:
df_target = train.groupby([var, "target"]).size().reset_index(name="count")
trace = go.Bar(x=df_target[var], y=df_target["count"], marker_color=df_target["target"].map({0: "royalblue", 1: "darkblue"}), name=var)
fig_cat_target.add_trace(trace, row=row, col=col)
col += 1
if col > num_cols:
col = 1
row += 1
fig_cat_target.update_layout(title="Distribución de Variables Categóricas por Target", showlegend=False, height=300 * num_rows)
fig_cat_target.show()
Análisis Bivariante: Variables Categóricas vs Target
El análisis bivariante permite observar la relación entre los niveles de cada variable cualitatuiva y la frecuencia del evento. En nuestro caso de uso específico, se analizan visualmente las proporciones relativas de siniestros dentro de cada categoría, lo que aporta una primera capa de entendimiento sobre el posible valor predictivo de las variables.
Variables con mayor poder discriminativo visual
ps_car_05_cat: Se identifican diferencias claras en la proporción de Clase 1 entre los niveles-1 (miss),0y1, sugiriendo que la codificación o agrupamiento puede capturar información relevante.ps_car_11: Se observa un patrón ascendente de proporción de eventos conforme aumenta el nivel, lo que podría interpretarse como una relación ordinal con el riesgo de siniestralidad.
Variables dominadas por un solo nivel
ps_ind_14,ps_car_06_cat,ps_car_10_cat: Aunque estas variables están fuertemente sesgadas hacia una única categoría dominante, algunas muestran leves diferencias en proporciones de eventos. No deben descartarse sin una validación empírica posterior, especialmente si la clase dominante presenta un comportamiento diferencial respecto al target.
Variables binarias o con clases desbalanceadas
ps_car_02_cat,ps_car_07_cat,ps_ind_02_cat: En estos casos, un nivel concentra la mayoría de los registros. A pesar de ello, la diferencia en la tasa de eventos entre niveles puede aportar valor, especialmente en modelos con tratamiento adecuado del desbalance.
Variables con muchos niveles o patrón difuso
ps_ind_15,ps_car_01_cat,ps_car_15, y varias de lasps_calc_*presentan patrones menos evidentes. Aun así, pueden contener relaciones útiles que no se aprecian a simple vista. Su análisis puede beneficiarse de binning supervisado o técnicas de reducción de cardinalidad.- Estas son las variables que, han entrado en los extremos de nuestro criterio/filtro inicial experto para considerar una variable cualitativa o cuantitativa.
Consideración del análisis visual
Este análisis exploratorio bivariante aporta una primera sensibilidad de carácter visual, que permite identificar variables con diferencias significativas en la distribución del target según sus niveles. Sin embargo, es importante destacar que la fase de selección de variables se basará en un fundamento complemente empírico, donde se evaluará cuantitativamente el potencial predictivo de cada variable mediante técnicas estadísticas como el Information Value (IV) o pruebas de independencia.
De este modo, se filtrarán aquellas categorías que, aunque visualmente prometedoras, no aporten valor real al modelo.
Por esa misma razón, no se exploran/explotan técnicas de representación visual más avanzada, pues será empíricamente como se identifique la capacidad de cada variable para explicar el evento de siniestro, que será usada para seleccionar las que mejor lo hagan, filtrando un subconjunto del total más acotado y más explicable/manipulable.
# Histogramas de Variables Continuas por Target en Grid:
num_vars_cont = len(continuous_vars)
num_rows_cont = (num_vars_cont // num_cols) + (num_vars_cont % num_cols > 0)
fig_cont_hist = sp.make_subplots(rows=num_rows_cont, cols=num_cols, subplot_titles=continuous_vars)
row, col = 1, 1
for var in continuous_vars:
trace0 = go.Histogram(x=train[train["target"] == 0][var], name=f"{var} (Target=0)", opacity=0.6, marker_color="royalblue")
trace1 = go.Histogram(x=train[train["target"] == 1][var], name=f"{var} (Target=1)", opacity=0.6, marker_color="darkblue")
fig_cont_hist.add_trace(trace0, row=row, col=col)
fig_cont_hist.add_trace(trace1, row=row, col=col)
col += 1
if col > num_cols:
col = 1
row += 1
fig_cont_hist.update_layout(title="Distribución de Variables Continuas por Target", showlegend=False, height=300 * num_rows_cont)
fig_cont_hist.show()
Análisis Bivariante: Variables Cuantitativas vs Target
Este análisis explora la relación entre las variables continuas y la variable objetivo (evento de siniestralidad). A través de histogramas de densidad superpuestos, se busca detectar diferencias en la forma, tendencia central o dispersión entre ambas clases, lo cual puede sugerir potencial predictivo.
Variables con diferencias visuales claras entre clases
ps_reg_03: La Clase 1 tiende a concentrarse en valores más bajos de la variable. Se evidencia una ligera asimetría que podría aprovecharse en modelos no lineales o mediante binning supervisado.ps_car_13: Se observa un desplazamiento hacia la izquierda en la distribución de la Clase 1, con un mayor número de eventos en rangos bajos. Este patrón sugiere valor informativo en la variable.ps_calc_10yps_calc_14: Presentan distribuciones con forma campaniforme (gaussianas truncadas), pero en ambos casos la Clase 1 muestra un ligero desplazamiento hacia valores inferiores. Aunque sutil, puede ser relevante en modelos sensibles a la distribución.
Variables con patrones menos claros o dominadas por picos
ps_car_11_cat: Exhibe una gran cantidad de valores discretos con una frecuencia extrema en un único punto (100). La Clase 1 parece estar algo más distribuida, pero la interpretación requiere codificación o binning.ps_car_12yps_car_14: Están dominadas por valores puntuales o discretos con alta frecuencia. Aunque las diferencias no son visualmente pronunciadas, podrían contener valor tras transformación.ps_car_14: Ligeros desplazamientos de la densidad de la Clase 1 hacia valores inferiores. Este comportamiento puede aprovecharse mediante normalización o categorización.
Consideraciones sobre la forma de la distribución
- Muchas variables, como
ps_calc_10yps_calc_14, presentan asimetría positiva (cola a la derecha), lo que sugiere la necesidad de técnicas comolog-transformorobust scaling. - En general, las diferencias entre clases no son tan evidentes como en algunas categóricas, pero sí existen pequeños desplazamientos que, combinados, pueden aportar valor al modelo final.
Consideración final
El análisis bivariante de variables cuantitativas permite detectar ligeros desplazamientos o cambios en la forma de la distribución del target. Aunque estas diferencias son, en la mayoría de los casos, sutiles a nivel visual, ofrecen una primera aproximación sobre la capacidad de segmentación del riesgo.
Del mismo modo que para las variables cualitativas, a pesar de este enfoque exploratorio, se destaca que la evaluación definitiva del valor predictivo se realizará de forma empírica mediante técnicas de selección de variables, validación cruzada y análisis de importancia. Esta fase visual actúa como una primera capa evaluadora, especialmente útil para identificar transformaciones necesarias y comprender el comportamiento del target dentro del rango de cada variable.
Finalmente, el establecimiento de estas variables en un patrón de normalización común, nos ayudará a observar/contrastarlas entre ellas, de una manera más objetiva. Esto lo veemos en el apartado de preprocesado previo a la ingesta a modelización.
# Heatmap de Correlación entre Variables Continuas:
plt.figure(figsize=(20, 10))
corr_matrix = train[continuous_vars].corr()
sns.heatmap(corr_matrix, annot=True, cmap="Blues", fmt=".2f", linewidths=0.5)
plt.title("Mapa de Calor de Correlación entre Variables Continuas")
plt.show()
El mapa muestra, en general, bajos niveles de correlación lineal entre las variables cuantitativas consideradas, con la excepción de la pareja ps_car_12 y ps_car_13, que presentan una correlación moderada (0.67). Este hallazgo sugiere un bajo riesgo de multicolinealidad estructural, lo cual refuerza la robustez del modelo frente a redundancias informativas.
Este resultado proporciona una base sólida para el uso de métricas complementarias como el Variance Inflation Factor (VIF) y el Kolmogorov-Smirnov (KS) en etapas posteriores del pipeline de modelado, con el objetivo de confirmar la independencia relativa de las variables y su capacidad discriminativa frente al target.
3. Preparación de datos
Valores N/A
# Constancia situacional a nivel viaual de lo que representa las variables a nivel N/A en el conjunto de datos:
plt.figure(figsize=(25, 10))
sns.heatmap(train.replace(-1,np.nan).isnull(), cmap="Blues", cbar=False, yticklabels=False)
plt.title("Mapa de Valores Faltantes (-1 tratados como NaN)")
plt.show()
En este conjunto de datos, los valores faltantes están codificados como -1. A pesar de que algunas variables presentan una elevada proporción de datos nulos, se ha optado por no eliminarlas del análisis.
La razón de esta decisión se fundamenta en el contexto del problema: estamos modelizando el riesgo de siniestralidad. En este ámbito, descartar variables informativas por el mero hecho de tener valores faltantes puede implicar una pérdida significativa de capacidad predictiva. De hecho, la presencia de valores faltantes puede ser indicativa de un patrón subyacente o comportamiento anómalo del asegurado.
Por ello, se adopta un enfoque diferenciado según la naturaleza de la variable:
- Sobre variables continuas (tales como
ps_reg_03,ps_car_12,ps_car_14... Como veremos a continuación) se imputan usando la mediana, y se normalizan posteriormente medianteQuantileTransformerhacia una distribución normal. Esta combinación permite mantener la información del contrato, sin introducir distorsión significativa en la distribución. - Las variables categóricas conservan el valor
-1como una categoría explícita. Esto permite identificar posibles patrones asociados a la falta de información, tratándolos como una categoría más en el modelado.
Este enfoque evita la pérdida de información valiosa (mediante eliminación de registros) y respeta la naturaleza original del dataset. La imputación y normalización aplicada en variables continuas busca reducir el sesgo introducido por los valores faltantes sin distorsionar las relaciones originales del modelo. Se evita la eliminación de columnas con NAs para preservar tanto la capacidad predictiva como la representatividad de los datos.
# Visualización ordenada de variables n/a:
train_na = (train == -1).sum()
train_na = train_na[train_na > 0]
train_na = train_na.sort_values(ascending=False)
df_na = pd.DataFrame({'Variable': train_na.index, 'num_na': train_na.values})
df_na = df_na.sort_values(by="num_na", ascending=False)
df_na
| Variable | num_na | |
|---|---|---|
| 0 | ps_car_03_cat | 411231 |
| 1 | ps_car_05_cat | 266551 |
| 2 | ps_reg_03 | 107772 |
| 3 | ps_car_14 | 42620 |
| 4 | ps_car_07_cat | 11489 |
| 5 | ps_ind_05_cat | 5809 |
| 6 | ps_car_09_cat | 569 |
| 7 | ps_ind_02_cat | 216 |
| 8 | ps_car_01_cat | 107 |
| 9 | ps_ind_04_cat | 83 |
| 10 | ps_car_02_cat | 5 |
| 11 | ps_car_11 | 5 |
| 12 | ps_car_12 | 1 |
Comparativa de métodos de imputación para variables cuantitativas
Dado el contexto del caso de uso y cómo se pretente abordar los NA's según el tipo de variable, se requiere imputar valores ausentes en variables continuas sin introducir sesgos significativos. A continuación, se presenta un análisis comparativo de los principales métodos de imputación, con foco en su aplicabilidad al dataset actual (tabular, no temporal, con presencia de outliers y distribución sesgada).
| Método | Ventajas | Desventajas |
|---|---|---|
| Media (mean) | Fácil de calcular, útil si la variable sigue distribución normal | Muy sensible a outliers y a distribuciones asimétricas |
| Mediana (median) SELECCIONADA | Robusta ante outliers y asimetría; conserva representatividad central | Puede ignorar información si la variable tiene estructura multimodal |
| KNN Imputation | Aprovecha la correlación entre variables; imputación más precisa | Costoso computacionalmente y sensible al escalado y ruido |
| Interpolación temporal (ffill/bfill) | Mantiene secuencia y dinámica temporal | Inaplicable en datos no ordenados temporalmente |
En este caso concreto, se opta por imputar los valores ausentes mediante la mediana, dada su robustez frente a valores extremos y su adecuación a variables sesgadas, a banda de la sencillez que supone su imputación. Tras la imputación, se aplica QuantileTransformer para convertir la variable a una distribución normal, lo cual favorece el rendimiento de modelos que suponen normalidad o requieren escalado en un futuro.
# Imputación de variables cuant. con NAs:
train_before = train.copy()
train[continuous_vars] = train[continuous_vars].replace(-1, np.nan)
# Imputo los NaN con la mediana, para a posteriori poder aplicar el quantile imputer:
normal_dist = QuantileTransformer(output_distribution='normal', random_state=42)
train[['ps_reg_03', 'ps_car_12', 'ps_car_14']] = train[['ps_reg_03', 'ps_car_12', 'ps_car_14']].fillna(train[['ps_reg_03', 'ps_car_12', 'ps_car_14']].median())
train[['ps_reg_03', 'ps_car_12', 'ps_car_14']] = normal_dist.fit_transform(train[['ps_reg_03', 'ps_car_12', 'ps_car_14']])
# Histogramas Antes vs Después de la Imputación ---
fig, axes = plt.subplots(3, 2, figsize=(16, 10))
for i, col in enumerate(['ps_reg_03', 'ps_car_12', 'ps_car_14']):
sns.histplot(train_before[col].dropna(), kde=True, bins=30, ax=axes[i, 0], color="royalblue", alpha=0.6)
axes[i, 0].set_title(f"Distribución de {col} Antes de la Imputación")
sns.histplot(train[col], kde=True, bins=30, ax=axes[i, 1], color="darkblue", alpha=0.6)
axes[i, 1].set_title(f"Distribución de {col} Después de la Imputación")
plt.tight_layout()
plt.show()
# Volvemos a visualizar la distr de NAs del dataset:
train.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 595212 entries, 0 to 595211 Data columns (total 59 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 id 595212 non-null int64 1 target 595212 non-null int64 2 ps_ind_01 595212 non-null int64 3 ps_ind_02_cat 595212 non-null int64 4 ps_ind_03 595212 non-null int64 5 ps_ind_04_cat 595212 non-null int64 6 ps_ind_05_cat 595212 non-null int64 7 ps_ind_06_bin 595212 non-null int64 8 ps_ind_07_bin 595212 non-null int64 9 ps_ind_08_bin 595212 non-null int64 10 ps_ind_09_bin 595212 non-null int64 11 ps_ind_10_bin 595212 non-null int64 12 ps_ind_11_bin 595212 non-null int64 13 ps_ind_12_bin 595212 non-null int64 14 ps_ind_13_bin 595212 non-null int64 15 ps_ind_14 595212 non-null int64 16 ps_ind_15 595212 non-null int64 17 ps_ind_16_bin 595212 non-null int64 18 ps_ind_17_bin 595212 non-null int64 19 ps_ind_18_bin 595212 non-null int64 20 ps_reg_01 595212 non-null float64 21 ps_reg_02 595212 non-null float64 22 ps_reg_03 595212 non-null float64 23 ps_car_01_cat 595212 non-null int64 24 ps_car_02_cat 595212 non-null int64 25 ps_car_03_cat 595212 non-null int64 26 ps_car_04_cat 595212 non-null int64 27 ps_car_05_cat 595212 non-null int64 28 ps_car_06_cat 595212 non-null int64 29 ps_car_07_cat 595212 non-null int64 30 ps_car_08_cat 595212 non-null int64 31 ps_car_09_cat 595212 non-null int64 32 ps_car_10_cat 595212 non-null int64 33 ps_car_11_cat 595212 non-null int64 34 ps_car_11 595212 non-null int64 35 ps_car_12 595212 non-null float64 36 ps_car_13 595212 non-null float64 37 ps_car_14 595212 non-null float64 38 ps_car_15 595212 non-null float64 39 ps_calc_01 595212 non-null float64 40 ps_calc_02 595212 non-null float64 41 ps_calc_03 595212 non-null float64 42 ps_calc_04 595212 non-null int64 43 ps_calc_05 595212 non-null int64 44 ps_calc_06 595212 non-null int64 45 ps_calc_07 595212 non-null int64 46 ps_calc_08 595212 non-null int64 47 ps_calc_09 595212 non-null int64 48 ps_calc_10 595212 non-null int64 49 ps_calc_11 595212 non-null int64 50 ps_calc_12 595212 non-null int64 51 ps_calc_13 595212 non-null int64 52 ps_calc_14 595212 non-null int64 53 ps_calc_15_bin 595212 non-null int64 54 ps_calc_16_bin 595212 non-null int64 55 ps_calc_17_bin 595212 non-null int64 56 ps_calc_18_bin 595212 non-null int64 57 ps_calc_19_bin 595212 non-null int64 58 ps_calc_20_bin 595212 non-null int64 dtypes: float64(10), int64(49) memory usage: 267.9 MB
# ratificación tras la imputación:
train_na = (train == -1).sum()
train_na = train_na[train_na > 0]
train_na = train_na.sort_values(ascending=False)
df_na = pd.DataFrame({'Variable': train_na.index, 'num_na': train_na.values})
df_na = df_na.sort_values(by="num_na", ascending=False)
df_na
| Variable | num_na | |
|---|---|---|
| 0 | ps_car_03_cat | 411231 |
| 1 | ps_car_05_cat | 266551 |
| 2 | ps_car_07_cat | 11489 |
| 3 | ps_ind_05_cat | 5809 |
| 4 | ps_car_09_cat | 569 |
| 5 | ps_ind_02_cat | 216 |
| 6 | ps_car_01_cat | 107 |
| 7 | ps_ind_04_cat | 83 |
| 8 | ps_car_02_cat | 5 |
| 9 | ps_car_11 | 5 |
Proceso de selección de variables. Reducción de la dimensionalidad del dataset de manera empírica:
El presente proceso pretende constatar la importancia de cada variable en relación al target/evento definitorio de nuestro planteamiento potencial de cuantificación del riesgo. Para ello, el siguiente apartado entabla un análisis procedimentado a partir de una serie de pruebas estadísticas que, con referencia a la industria, nos aportan consciencia situacional acerca de si una variable "x" es relevante en relación con el evento que quiere explicar.
Selección de Variables: Estrategia Combinada
Dada la elevada dimensionalidad del dataset original, se plantea la necesidad de aplicar una estrategia de reducción de variables que permita:
- Mejorar la interpretabilidad del modelo
- Reducir el riesgo de sobreajuste
- Facilitar la validación y el control del riesgo
Para ello, se aplica un enfoque combinado de selección de variables, basado en criterios estadísticos tanto paramétricos como no paramétricos.
1. Cálculo de indicadores por variable
Se evalúa cada variable mediante diferentes indicadores de relevancia y estabilidad predictiva:
- Gini index: derivado del
AUC, mide la capacidad discriminativa respecto al target. - Information Value (IV): cuantifica la separación entre clases tras binning por cuantiles; muy usado en scoring.
- Importancia por permutación: criterio no paramétrico basado en Random Forest para estimar sensibilidad del modelo.
- Variance Inflation Factor (VIF): mide la multicolinealidad entre variables cuantitativas.
- Kolmogorov-Smirnov (KS): mide la separación entre distribuciones del target en variables continuas.
2. Agregación de métricas: Score de selección
Para sintetizar la relevancia predictiva de cada variable, se construye un score agregado como:
Score = Gini + Information Value
Este score combina la capacidad discriminativa (gini) con la estabilidad predictiva (IV), integrando dos enfoques complementarios.
3. Umbral óptimo de selección con el método de Otsu
Para determinar un punto de corte que separe las variables relevantes de las irrelevantes, se aplica el método de Otsu sobre la distribución del score combinado. Este método, originalmente desarrollado para segmentación en procesamiento de imagen, permite encontrar el umbral que minimiza la varianza intra-grupo y maximiza la varianza entre grupos.
El resultado es una dicotomización objetiva y reproducible del conjunto de variables, que permite seleccionar N variables óptimas para construir el dataset final.
4. Resultado: Subconjunto de variables seleccionadas
- Variables seleccionadas: aquellas con
Score ≥ umbral_otsu - Variables descartadas: aquellas con
Score < umbral_otsu
Este subconjunto representa la versión controlada y reducida del dataset, sobre el cual se entrenarán los modelos de predicción de siniestralidad.
Coeficiente de Gini
El coeficiente de Gini es una métrica fundamental para evaluar la capacidad discriminante de una variable respecto a un objetivo binario. Altamente usasa en problemas de riesgo de crédito, extendemos su uso en el proyecto para evaluar el poder discriminante de las variables contra el target binario. La métrica, deriva del área bajo la curva ROC (AUC), siendo robusta al tomar en cuenta cuan sensible y específico es el clasificador, en términos de la matriz de confusión. Se calcula como:
Gini = 2 · AUC - 1
Este coeficiente refleja la capacidad de una variable para distinguir entre eventos (Y=1) y no eventos (Y=0), siendo especialmente, tal y como se ha comentado, útil en contextos de modelización del riesgo.
Relación con el estadístico D de Somers'
Estadísticamente, el coeficiente de Gini es equivalente al estadístico D de Somers', siendo por tanto una medida de asociación bidireccional (que tiene en cuenta el sentido de la ordenación) frente al target.
D₍XY₎ = 2 · AUC - 1 = Gini
Ambos miden el grado de concordancia entre el predictor y el target binario. A diferencia de la Tau de Kendall (simétrica), Somers' D es direccional y por tanto es más adecuado para evaluar la capacidad discriminante de una variable independiente sobre una dependiente.
Interpretación numérica del Gini
| Valor Gini | Interpretación |
|---|---|
| 0.00 - 0.10 | Sin capacidad predictiva |
| 0.10 - 0.30 | Baja capacidad predictiva |
| 0.30 - 0.50 | Buena capacidad predictiva |
| 0.50 - 0.70 | Muy buena capacidad predictiva |
| 0.70 - 1.00 | Excelente capacidad predictiva |
Aplicabilidad:
- Tipo de variables: se aplicará sobre todas las variables, tanto cualitativas como cuantitativas. En el caso de variables multinivel, se realizará una transformación mediante one-hot encoding para evaluar el poder discriminante a nivel de cada categoría. Esta estrategia permite descomponer una variable categórica en múltiples variables binarias, capturando la información individual de cada nivel de forma explícita.
- Tratamiento de valores faltantes: Dado que en este conjunto de datos los valores faltantes se codifican como
-1, se realizará una conversión explícita aNaNantes de calcular el AUC. Esto es necesario porque la funciónroc_auc_scorede scikit-learn no admiteNaNcomo entrada, y porque mantener el-1podría introducir un sesgo significativo en la medición de la capacidad discriminativa de la variable. - Criterio de selección: El coeficiente de Gini no se utilizará como umbral absoluto según los valores establecidos en la literatura (e.g., Gini > 0.3 indica buena capacidad predictiva), sino como una métrica relativa dentro del conjunto de variables analizadas.
Este procedimiento permite identificar variables que presentan una estructura informativa/discriminante relevante respecto al target, incluso si su relación no es lineal ni monotónica. Su valor añadido radica en que permite evaluar la capacidad discriminativa sin asumir ningún modelo subyacente.
def gini_01(df, target, binary_vars, categorical_vars, continuous_vars):
"""
Calcula el coeficiente de Gini para cada variable del dataset según su tipo:
- Continuas: AUC-ROC -> Gini = 2 * AUC - 1
- Binarias: AUC-ROC -> Gini = 2 * AUC - 1
- Categóricas: One-hot encoding y promedio del Gini de cada categoría.
"""
# Imputación de n/a real:
df = df.replace(-1, np.nan) # Reemplazo -1 por NaN para que no se tengan en cuenta, pero NO los elimino (efecto solo contra cualitativas)
gini_dict = {}
# Variables binarias y continuas: usamos AUC-ROC directamente
for col in binary_vars + continuous_vars:
try:
auc = roc_auc_score(df[target], df[col])
gini_dict[col] = 2 * auc - 1
except:
gini_dict[col] = np.nan
# Variables categóricas: One-hot encoding y cálculo de Gini por categoría
for col in categorical_vars:
temp_df_dummies = pd.get_dummies(df[col], prefix=col, drop_first=True)
gini_scores = []
for dummy_col in temp_df_dummies.columns:
try:
auc = roc_auc_score(df[target], temp_df_dummies[dummy_col])
gini_scores.append(2 * auc - 1)
except:
gini_scores.append(np.nan)
gini_dict[col] = np.nanmean(gini_scores)
gini_df = pd.DataFrame(list(gini_dict.items()), columns=['Feature', 'Gini'])
gini_df = gini_df.sort_values(by='Gini', ascending=False)
return gini_df
def plot_gini_results(gini_df):
"""
Visualiza el coeficiente de Gini en un gráfico de barras.
"""
gini_df = gini_df.sort_values(by="Gini", ascending=False)
gini_thresholds = {
"Neutro": 0,
}
plt.figure(figsize=(20, 14))
color = (0/255, 155/255, 200/255)
ax = sns.barplot(y=gini_df["Feature"], x=gini_df["Gini"], color=color)
for label, value in gini_thresholds.items():
plt.axvline(x=value, color="red", linestyle="dashed", linewidth=2, label=f"{label} ({value:.2f})")
plt.xlabel("Gini", fontsize=12)
plt.ylabel("Feature", fontsize=12)
plt.title("Gini de las Variables", fontsize=14, fontweight="bold")
plt.legend()
plt.show()
# --------------------------------------------------
#---------EJECUCIÓN y PLOT--------------------------
# --------------------------------------------------
gini_total = gini_01(train, target, binary_vars, categorical_vars, continuous_vars)
plot_gini_results(gini_total)
Information Value (IV)
El Information Value (IV), junto con el Weight of Evidence (WoE), es una de las métricas más utilizadas para evaluar la capacidad predictiva de una variable independiente respecto a una variable objetivo binaria. Estas métricas, derivadas de la teoría de la información, son ampliamente aplicadas en contextos de riesgo de crédito y scoring de carteras/clientes.
Definición formal del Weight of Evidence (WoE)
El WoE mide el grado de separación entre las distribuciones de las dos clases de la variable objetivo (Y=1 vs Y=0) dentro de cada grupo o bin de una variable independiente X. Se calcula como:
WoEⱼ = ln( (N₁ⱼ / N₁) / (N₀ⱼ / N₀) )
N₁ⱼ: número de eventos (Y=1) en el grupo jN₀ⱼ: número de no eventos (Y=0) en el grupo jN₁,N₀: totales de eventos y no eventos en el dataset
El WoE tiene propiedades clave:
WoE = 0indica proporciones idénticas entre clases (no información)WoE > 0: mayor peso del grupo en la clase positivaWoE < 0: mayor peso del grupo en la clase negativa
Cálculo e interpretación del Information Value (IV)
El IV mide la capacidad global de una variable para discriminar entre eventos y no eventos. Se define como:
IV = Σ ( (N₁ⱼ / N₁ - N₀ⱼ / N₀) · WoEⱼ )
La interpretación nominal clásica del IV es la siguiente:
| Valor IV | Interpretación |
|---|---|
| IV < 0.02 | No informativa |
| 0.02 < IV < 0.1 | Débilmente predictiva |
| 0.1 < IV < 0.3 | Moderadamente predictiva |
| IV > 0.3 | Altamente predictiva |
Aplicabilidad:
- Tipo de variables: se aplicará sobre todas las variables, tanto cualitativas como cuantitativas.
- Tratamiento de valores faltantes: se preservan los nulos reales (convertidos a
NaN), diferenciándolos del valor-1. Esta elección permite que el modelo distinga efectivamente los casos con ausencia de información sin introducir sesgo al tratar-1como una categoría legítima. - Transformación: las variables continuas se binarizarán mediante binning cuantílico (percentiles) para obtener grupos que preserven la distribución y permitan un cálculo estable del WoE e IV.
- Criterio de selección: el IV se utilizará como métrica relativa en el conjunto de variables, no como umbral absoluto, tal y como se ha hecho en el caso del Gini.
Este procedimiento permite identificar variables que presentan una estructura informativa relevante respecto al target, incluso si su relación no es lineal ni monotónica. Su valor añadido radica en que permite evaluar la capacidad discriminativa sin asumir ningún modelo subyacente.
# --------------------------------------------------------------
# 2. Information Value (IV)-------------------------------------
# --------------------------------------------------------------
def iv_02(df, target_col, binary_vars, categorical_vars, continuous_vars, num_bins=10):
"""
Calcula el Information Value (IV) para cada variable respecto al target.
- Variables continuas: binning (división en num_bins partes).
- Variables binarias y categóricas: proporciones de la variable target en cada categoría.
Parámetros:
- df: DataFrame con los datos.
- target_col: Nombre de la variable objetivo.
- binary_vars: Lista de variables binarias.
- categorical_vars: Lista de variables categóricas.
- continuous_vars: Lista de variables continuas.
- num_bins: Número de bins para variables continuas.
Retorno:
- iv_df: DataFrame con los valores IV de cada variable.
"""
iv_dict = {}
df = df.drop(columns=['id'], axis=1)
for col in df.columns:
if col == target_col:
continue
temp_df = df[[col, target_col]].copy()
# Reemplazo valores -1 con NaN para tratar datos ausentes pero NO elimino (misma lógica que gini):
temp_df[col] = temp_df[col].replace(-1, np.nan)
# Aplico binning solo a variables continuas (alnum_bins =10 son deciles)
if col in continuous_vars:
temp_df[col] = pd.qcut(temp_df[col], q=num_bins, duplicates="drop")
# Tabla de frecuencia de eventos y no eventos
iv_table = temp_df.groupby(col, observed=False)[target_col].agg(['count', 'sum'])
iv_table.columns = ['Total', 'Events']
iv_table['Non-Events'] = iv_table['Total'] - iv_table['Events']
# Cálculo % de eventos y % de no eventos en cada grupo
iv_table['% Events'] = iv_table['Events'] / iv_table['Events'].sum()
iv_table['% Non-Events'] = iv_table['Non-Events'] / iv_table['Non-Events'].sum()
iv_table['% Events'] = np.where(iv_table['% Events'] == 0, 0.0001, iv_table['% Events'])
iv_table['% Non-Events'] = np.where(iv_table['% Non-Events'] == 0, 0.0001, iv_table['% Non-Events'])
# WoE
iv_table['WOE'] = np.log(iv_table['% Non-Events'] / iv_table['% Events'])
# IV
iv_table['IV'] = (iv_table['% Non-Events'] - iv_table['% Events']) * iv_table['WOE']
iv_dict[col] = iv_table['IV'].sum()
iv_df = pd.DataFrame(list(iv_dict.items()), columns=['Feature', 'Information Value (IV)'])
iv_df = iv_df.sort_values(by='Information Value (IV)', ascending=False)
return iv_df
def plot_iv_results(iv_df):
"""
Visualiza el Information Value (IV) de las variables en un gráfico de barras.
"""
iv_df = iv_df.sort_values(by="Information Value (IV)", ascending=False)
iv_thresholds = {
"Bajo": 0.005,
"Medio": 0.01,
"Alto": 0.05
}
plt.figure(figsize=(20, 14))
color = (0/255, 155/255, 200/255)
ax = sns.barplot(y=iv_df["Feature"], x=iv_df["Information Value (IV)"], color=color)
for label, value in iv_thresholds.items():
plt.axvline(x=value, color="red", linestyle="dashed", linewidth=2, label=f"{label} ({value:.2f})")
plt.xlabel("Information Value (IV)", fontsize=12)
plt.ylabel("Feature", fontsize=12)
plt.title("Information Value (IV) de las Variables", fontsize=14, fontweight="bold")
plt.legend()
plt.show()
# --------------------------------------------------
#---------EJECUCIÓN y PLOT--------------------------
# --------------------------------------------------
iv_total = iv_02(train, target, binary_vars, categorical_vars, continuous_vars)
plot_iv_results(iv_total)
Importancia por Permutación (A partir de RF Naive)
La Importancia por Permutación es una técnica ampliamente adoptada en Machine Learning para evaluar la relevancia de cada variable en la predicción del modelo. Su principio es simple pero de ahí radica su potencia: desordenar (permutar aleatoriamente) los valores de una variable y observar la degradación del rendimiento del modelo.
Este método es especialmente eficaz en modelos no lineales como Random Forest, Gradient Boosting o Redes Neuronales, ya que permite capturar relaciones complejas sin asumir estructuras paramétricas. No obstante, en el presente caso de uso, se usará Random Forest, si bien la elección no sigue nungún prejuicio más que tener un mismo modelo sobre el que contrastar el deterioro del predictor de cara a evaluar la importancia.
Definición formal
Sea \( f \) el modelo entrenado, y \( M(f) \) una métrica de rendimiento (por ejemplo, AUC, accuracy o RMSE). La importancia de la variable \( X_j \) se define como:
Imp(Xⱼ) = M(f) - M(f_{π(Xⱼ)})
M(f): rendimiento original del modeloM(f_{π(Xⱼ)}): rendimiento tras permutar aleatoriamente los valores deXⱼπ(Xⱼ): permutación aleatoria de los valores de la variableXⱼ
Si la métrica del modelo se reduce significativamente tras la permutación, la variable es considerada informativa. Si la métrica se mantiene constante, la variable es irrelevante para el modelo.
Aplicabilidad:
- Tipo de variables: todas las variables del dataset, tanto cualitativas (binarizadas) como cuantitativas, son incluidas en la evaluación.
- Tratamiento de valores faltantes: se preservan los nulos reales (convertidos a
NaN), diferenciándolos del valor-1. Esta elección permite que el modelo distinga efectivamente los casos con ausencia de información sin introducir sesgo al tratar-1como una categoría legítima. - Criterio de uso: la importancia obtenida se utilizará como medida relativa para comparar variables entre sí dentro del conjunto, sin aplicar un umbral absoluto. Este enfoque se alinea con la filosofía aplicada previamente al
Information Valuey alGini, buscando consistencia metodológica en la selección final de atributos relevantes.
# --------------------------------------------------------------
# 3. Permutation Importance ------------------------------------
# --------------------------------------------------------------
def importance_03(df):
"""
Cálculo de la Importancia por permutación basada en resultados de RF naive.
Parámetros:
df (pd.DataFrame): DataFrame con variables numéricas.
Retorna:
pd.DataFrame: DataFrame con los valores de Peerm.Imp de cada variable.
"""
# Separación de features y target
X = df.drop(columns=['target', 'id']).replace(-1, np.nan) # Reemplazo -1 por NaN y NO elimino
y = df['target']
# Defino el modelo naive sin hiperparámetros optimizados:
model = RandomForestClassifier(
n_estimators=20,
max_depth=8,
max_features="sqrt",
bootstrap=True,
n_jobs=-1,
random_state=42
)
model.fit(X, y)
# Calculo la Permutation Importance:
perm_importance = permutation_importance(
model, X, y,
n_repeats=5,
random_state=42,
scoring='accuracy',
n_jobs=-1
)
perm_importance_df = pd.DataFrame({
'Feature': X.columns,
'Permutation Importance': (perm_importance.importances_mean*10000)
# multiplico por factor 1·10^4, solo a efectos de comparación relativa
}).sort_values(by='Permutation Importance', ascending=False)
return perm_importance_df
def plot_permutation_importance_results(perm_importance_df):
"""
Visualiza la Permutation Importance de las variables en un gráfico de barras.
"""
plt.figure(figsize=(20, 14))
color = (0/255, 155/255, 200/255)
ax = sns.barplot(y=perm_importance_df["Feature"], x=perm_importance_df["Permutation Importance"], color=color)
plt.xlabel("Permutation Importance", fontsize=12)
plt.ylabel("Feature", fontsize=12)
plt.title("Permutation Importance de las Variables", fontsize=14, fontweight="bold")
plt.show()
# --------------------------------------------------
#---------EJECUCIÓN Y PLOT--------------------------
# --------------------------------------------------
permutation_total = importance_03(train)
plot_permutation_importance_results(permutation_total)
Factor de Inflación de la Varianza (VIF)
El Factor de Inflación de la Varianza (VIF) es una métrica que evalúa el grado de multicolinealidad entre variables predictoras en un modelo de regresión.al cuantifica cuánto se incrementa la varianza de los coeficientes estimados debido a la correlación entre variables independientes. En nuestro contexto, tiene sentido observar si existe multicolinealidad entre las variables cuantitativas de cara a evitar un potencial sesgo por esta razón cuando se adentren las variables en un modelo y puedan interaccionar.
Fórmula del VIF
Sea \( R_j^2 \) el coeficiente de determinación de la regresión lineal de la variable \( X_j \) contra todas las demás variables. Entonces:
VIFⱼ = 1 / (1 - Rⱼ²)
Interpretación del VIF
| Valor VIF | Interpretación |
|---|---|
| VIF < 5 | Multicolinealidad baja |
| 5 ≤ VIF < 10 | Multicolinealidad moderada |
| VIF ≥ 10 | Multicolinealidad alta (problema grave) |
Aplicabilidad:
- Tipo de variables: únicamente se aplicará sobre variables continuas, ya que el concepto de varianza inflada se define en el contexto de regresión lineal.
- Tratamiento de valores nulos: se eliminan los registros con valores
NaNen las variables continuas antes del cálculo del VIF, para evitar errores numéricos. - Criterio de selección: aquellas variables que presenten VIF elevados serán eliminadas del conjunto final de predictores, con el objetivo de reducir la redundancia y mejorar la estabilidad del modelo.
# --------------------------------------------------------------
# 4. Variance Inflation Factor (VIF) ---------------------------
# --------------------------------------------------------------
def vif_04(df):
"""
Calcula el Variance Inflation Factor (VIF) para detectar colinealidad entre variables continuas.
- Elimina columnas con varianza cero para evitar errores.
- Maneja valores nulos eliminando filas con NaN antes de calcular el VIF.
Parámetros:
df (pd.DataFrame): DataFrame con variables numéricas.
Retorna:
pd.DataFrame: DataFrame con los valores de VIF de cada variable.
"""
# Elimino columnas constantes (varianza cero)
df = df.loc[:, df.var() > 0]
# Quito filas con valores n/a y control:
df = df.replace(-1, np.nan).dropna() # En continuas, elimino N/A's, para que no afecten a la distribucioón
if df.shape[1] == 0:
return pd.DataFrame(columns=["Feature", "VIF"])
# Calculo VIF
vif_data = pd.DataFrame()
vif_data["Feature"] = df.columns
vif_data["VIF"] = [variance_inflation_factor(df.values, i) for i in range(df.shape[1])]
return vif_data
def plot_vif_results(df):
"""
Visualiza el VIF de las variables en un gráfico de barras.
"""
plt.figure(figsize=(20, 6))
color = (0/255, 155/255, 200/255)
ax = sns.barplot(y=df["Feature"], x=df["VIF"], color=color)
plt.xlabel("VIF", fontsize=12)
plt.ylabel("Feature", fontsize=12)
plt.title("VIF de las Variables", fontsize=14, fontweight="bold")
plt.show()
# --------------------------------------------------
#---------EJECUCIÓN (solo varibles continuas) ----
# --------------------------------------------------
scaler = StandardScaler()
if continuous_vars:
X_continuous = train[continuous_vars].copy().replace(-1, np.nan).dropna()
if X_continuous.shape[1] > 1:
X_continuous_scaled = scaler.fit_transform(X_continuous)
else:
X_continuous_scaled = X_continuous.values
vif_df = vif_04(pd.DataFrame(X_continuous_scaled, columns=X_continuous.columns))
else:
vif_df = pd.DataFrame(columns=["Feature", "VIF"])
# VIF descendente
vif_total = vif_df.sort_values(by="VIF", ascending=False).reset_index(drop=True)
vif_total
plot_vif_results(vif_total)
Kolmogorov-Smirnov (KS)
El test de Kolmogorov-Smirnov (KS) es una prueba no paramétrica utilizada para evaluar si dos muestras provienen de la misma distribución. En el contexto de modelos de clasificación binaria, como es nuestro caso, se emplea para medir la capacidad discriminante de una variable continua entre dos clases: eventos (Y=1) y no eventos (Y=0). El estadístico KS se basa en la diferencia máxima entre las funciones de distribución acumulada (CDF) de ambas clases:
KS = max |F₁(x) - F₀(x)|
F₁(x): CDF empírica de los registros conY=1F₀(x): CDF empírica de los registros conY=0
Cuanto mayor es el valor de KS, mayor es la separación entre las dos distribuciones y, por tanto, mayor es la capacidad de la variable para discriminar entre clases. Un valor de KS cercano a cero indica que la variable no aporta información relevante para distinguir entre eventos y no eventos.
Aplicabilidad:
- Tipo de variables: el test se aplicará exclusivamente a variables continuas, ya que su definición se basa en funciones de distribución acumulada.
- Tratamiento de valores nulos: se eliminan los registros con valores
NaNen las variables continuas antes del cálculo del estadístico, para no alterar la forma de las distribuciones.
# --------------------------------------------------------------
# 5. Kolmogorov-Smirnov (KS Test) ------------------------------
# --------------------------------------------------------------
def ks_test(df, target, continuous_vars):
"""
Calcula el estadístico KS para variables continuas.
Parámetros:
- df: DataFrame con los datos.
- target: Variable objetivo binaria.
- continuous_vars: Lista de variables continuas.
Retorna:
- DataFrame con los valores KS ordenados en orden descendente.
"""
ks_results = []
temp_df = df.copy().replace(-1, np.nan).dropna() # KS no soporta NaN, y los elimino para que no afecten a la distribución.
for col in continuous_vars:
ks_stat, _ = ks_2samp(temp_df[col][temp_df[target] == 1], temp_df[col][temp_df[target] == 0])
ks_results.append({"Feature": col, "KS": ks_stat})
ks_df = pd.DataFrame(ks_results).sort_values(by="KS", ascending=False).reset_index(drop=True)
return ks_df
def plot_ks_results(ks_df):
"""
Visualiza el Kolmogorov-Smirnov (KS) de las variables en un gráfico de barras.
"""
plt.figure(figsize=(20, 6))
color = (0/255, 155/255, 200/255)
ax = sns.barplot(y=ks_df["Feature"], x=ks_df["KS"], color=color)
plt.xlabel("Kolmogorov-Smirnov (KS)", fontsize=12)
plt.ylabel("Feature", fontsize=12)
plt.title("Kolmogorov-Smirnov (KS) de las Variables", fontsize=14, fontweight="bold")
plt.show()
# --------------------------------------------------
#---------EJECUCIÓN (solo variables continuas) -----
# --------------------------------------------------
ks_total=ks_test(train, target, continuous_vars)
plot_ks_results(ks_total)
Resumen del proceso de selección y justificación:
El proceso de selección de variables cuenta con la finalidad de reducir el espacio dimensional de entrada a la solucion predictiva, con el objetivo de reducir la complejidad dimensional trasladada por un dataset ofuscado. En línea con el proceso, se ha evaluado el Gini, el Information Value y la Importancia por Permutación sobre todos los predictores, así como el VIF y el test de KS para solamente aquellos predictores continuos.
Iniciando precisamente por estos últimos, vemos como tanto el VIF como el KS, no muestran evidencias que nos lleven a considerar excluir alguno de los predictores contínuos de nuestro conjunto de datos. Todos poseen un VIF y un KS moderadamente bajo.
En lo relativo a las métricas puramente de cuantificación de importancia, (Gini, IV y Imp. por permutación) y su resultado, se va a proceder de manera totalmente objetiva en base a la siguiente consideración:
Poseemos una "long-list" con todos los predictores y el objetivo es generar una short-list con los que hayan demostrado mejor performance en el proceso de selección.
Por ello, a continuación, en los siguientes apartados, se muestra un tabular con el resultado de cada test para cada predictor. Se propone una serie de umbrales para marcar visualmente aquellos con un mejor performance. No obstante, para realizar una selección con fundamento empírico, vamos a tomar como ejes principales el Gini y el IV, pues son dos tests que basan su desempeño en la capacidad de separación de clases, ambos informándonos del poder discriminante de las variables. Por ello, generamos una métrica adicional, de evaluación:
Sum Gini + IV: con la suma de abs(Gini), dado su caracter bidireccional y el IV.
Sum Gini + IV + Perm: ídem a la anterior pero incorpora el resultado de Importancia por permutación.
En definitiva, se procede a todos los efectos con el resultado en términso de "rankeado2" que nos proporciona Sum Gini + IV, dado que son los métodos más explicables y trazables a efectos cuantitativos. No obstante y, a modo de evaluación de la robustez del método, se contrasta el efecto adicional de la Importancia por permutación trazada a partir del Random Forest "naive". En particular se ve como a nivel genérico, existe una dependencia lineal entre Sum Gini + IV y Sum Gini + IV + Perm, lo que nos es indicativo como la Importancia por permutación capta una sensibilidad similar a ala de únicamente Gini e IV.
Finalmente, dada esta dependencia, se procede con Sum Gini + IV, a efectos de generar la Short List de entrada a Modelo. El paso que nos deberemos plantgear, dado un ranking de variables ordenados por esta métrica de evaluación agregada, es "dónde" acotar el ranking. Esa tarea, precisamente es la que sigue previamente a la modelización, siempre con un objetivo de proporcionar un umbral de corte basado en los datos y pot tanto cuantitativo, evitando el "sentido Experto" que puede ser un criterio vago frente a un conjunto de datos ofuscado, sobre el que no se tiene información de negocio.
# Creación de un DataFrame vacío para almacenar los resultados consolidados del análisis:
final_df = pd.DataFrame()
# Unión condicional y robusta si estos existen:
if 'gini_total' in locals():
final_df = gini_total
if 'iv_total' in locals():
final_df = final_df.merge(iv_total, on="Feature", how="outer") if not final_df.empty else iv_total
if 'permutation_total' in locals():
final_df = final_df.merge(permutation_total, on="Feature", how="outer") if not final_df.empty else permutation_total
if 'vif_total' in locals():
final_df = final_df.merge(vif_total, on="Feature", how="outer") if not final_df.empty else vif_total
if 'ks_total' in locals():
final_df = final_df.merge(ks_total, on="Feature", how="outer") if not final_df.empty else ks_total
# Muestro:
final_df
| Feature | Gini | Information Value (IV) | Permutation Importance | VIF | KS | |
|---|---|---|---|---|---|---|
| 0 | ps_calc_01 | 1.001044e-04 | 6.755897e-04 | 0.006720 | NaN | NaN |
| 1 | ps_calc_02 | 4.726956e-04 | 4.922961e-04 | 0.013441 | NaN | NaN |
| 2 | ps_calc_03 | 4.358367e-04 | 3.601328e-04 | 0.010080 | NaN | NaN |
| 3 | ps_calc_04 | 1.581859e-04 | 1.469187e-04 | 0.000000 | NaN | NaN |
| 4 | ps_calc_05 | 1.238698e-04 | 2.919458e-04 | 0.000000 | NaN | NaN |
| 5 | ps_calc_06 | 1.743624e-07 | 9.493726e-04 | 0.026881 | NaN | NaN |
| 6 | ps_calc_07 | 1.213284e-05 | 5.128267e-04 | 0.013441 | NaN | NaN |
| 7 | ps_calc_08 | 1.743624e-06 | 4.088823e-04 | 0.013441 | NaN | NaN |
| 8 | ps_calc_09 | -8.423842e-05 | 2.967627e-04 | 0.000000 | NaN | NaN |
| 9 | ps_calc_10 | 3.454968e-03 | 4.638004e-04 | 0.020161 | 1.000010 | 0.008748 |
| 10 | ps_calc_11 | -7.620515e-07 | 1.392707e-03 | 0.023521 | NaN | NaN |
| 11 | ps_calc_12 | -2.098402e-04 | 5.833618e-04 | 0.006720 | NaN | NaN |
| 12 | ps_calc_13 | -1.113164e-04 | 2.539179e-04 | 0.020161 | NaN | NaN |
| 13 | ps_calc_14 | 2.907907e-03 | 3.185615e-04 | 0.003360 | 1.000004 | 0.004217 |
| 14 | ps_calc_15_bin | -8.577890e-04 | 6.867809e-06 | 0.000000 | NaN | NaN |
| 15 | ps_calc_16_bin | 1.609098e-03 | 1.109028e-05 | 0.000000 | NaN | NaN |
| 16 | ps_calc_17_bin | -4.512420e-04 | 8.240801e-07 | 0.006720 | NaN | NaN |
| 17 | ps_calc_18_bin | 1.333759e-03 | 8.678863e-06 | 0.013441 | NaN | NaN |
| 18 | ps_calc_19_bin | -4.435504e-03 | 8.682940e-05 | 0.000000 | NaN | NaN |
| 19 | ps_calc_20_bin | -2.061451e-03 | 3.290504e-05 | 0.000000 | NaN | NaN |
| 20 | ps_car_01_cat | -3.214540e-04 | 3.670219e-02 | 0.235210 | NaN | NaN |
| 21 | ps_car_02_cat | -6.322968e-02 | 2.522606e-02 | -0.003360 | NaN | NaN |
| 22 | ps_car_03_cat | 6.276192e-02 | 1.026582e-02 | 0.114245 | NaN | NaN |
| 23 | ps_car_04_cat | 7.910204e-03 | 3.495814e-02 | 0.084004 | NaN | NaN |
| 24 | ps_car_05_cat | 3.309124e-02 | 1.963341e-05 | 0.010080 | NaN | NaN |
| 25 | ps_car_06_cat | 9.554776e-04 | 3.381718e-02 | 0.023521 | NaN | NaN |
| 26 | ps_car_07_cat | -4.448442e-02 | 9.582240e-03 | 0.144486 | NaN | NaN |
| 27 | ps_car_08_cat | -4.057461e-02 | 1.087619e-02 | 0.003360 | NaN | NaN |
| 28 | ps_car_09_cat | 6.846248e-03 | 1.678575e-02 | 0.016801 | NaN | NaN |
| 29 | ps_car_10_cat | 2.398088e-04 | 3.173514e-05 | 0.000000 | NaN | NaN |
| 30 | ps_car_11 | -4.398058e-03 | 1.275266e-02 | 0.000000 | NaN | NaN |
| 31 | ps_car_11_cat | 2.483150e-02 | 6.386553e-03 | 0.094084 | 1.003953 | 0.035152 |
| 32 | ps_car_12 | 1.103094e-01 | 3.995435e-02 | 0.057123 | 2.212193 | 0.083747 |
| 33 | ps_car_13 | 1.530936e-01 | 7.206788e-02 | 0.100804 | 1.913309 | 0.115247 |
| 34 | ps_car_14 | 2.394807e-02 | 1.879454e-02 | 0.026881 | 1.414189 | 0.061229 |
| 35 | ps_car_15 | 5.981435e-04 | 3.005593e-02 | 0.010080 | NaN | NaN |
| 36 | ps_ind_01 | 4.136978e-03 | 1.176617e-02 | 0.003360 | NaN | NaN |
| 37 | ps_ind_02_cat | 4.465980e-03 | 1.078667e-03 | 0.235210 | NaN | NaN |
| 38 | ps_ind_03 | -1.781159e-03 | 2.879527e-02 | 0.060483 | NaN | NaN |
| 39 | ps_ind_04_cat | 2.607426e-02 | 2.912871e-03 | 0.369616 | NaN | NaN |
| 40 | ps_ind_05_cat | 8.701502e-03 | 2.999956e-02 | 0.332655 | NaN | NaN |
| 41 | ps_ind_06_bin | -8.868646e-02 | 3.459194e-02 | 0.067203 | NaN | NaN |
| 42 | ps_ind_07_bin | 7.979267e-02 | 3.081699e-02 | 0.087364 | NaN | NaN |
| 43 | ps_ind_08_bin | 2.597142e-02 | 4.658925e-03 | 0.050402 | NaN | NaN |
| 44 | ps_ind_09_bin | -1.707763e-02 | 1.999937e-03 | 0.023521 | NaN | NaN |
| 45 | ps_ind_10_bin | 1.869872e-04 | 7.717706e-05 | 0.000000 | NaN | NaN |
| 46 | ps_ind_11_bin | 4.447789e-04 | 1.049083e-04 | 0.000000 | NaN | NaN |
| 47 | ps_ind_12_bin | 4.029881e-03 | 1.468236e-03 | 0.000000 | NaN | NaN |
| 48 | ps_ind_13_bin | 4.039361e-04 | 1.454926e-04 | 0.000000 | NaN | NaN |
| 49 | ps_ind_14 | 1.034105e-03 | 1.464092e-03 | 0.000000 | NaN | NaN |
| 50 | ps_ind_15 | -7.434278e-04 | 1.612273e-02 | 0.040322 | NaN | NaN |
| 51 | ps_ind_16_bin | -7.017560e-02 | 2.113447e-02 | 0.084004 | NaN | NaN |
| 52 | ps_ind_17_bin | 6.450002e-02 | 3.288829e-02 | 0.117605 | NaN | NaN |
| 53 | ps_ind_18_bin | 8.761238e-03 | 5.785738e-04 | 0.000000 | NaN | NaN |
| 54 | ps_reg_01 | -3.228884e-04 | 2.400182e-02 | 0.050402 | NaN | NaN |
| 55 | ps_reg_02 | 6.342803e-04 | 4.186385e-02 | 0.040322 | NaN | NaN |
| 56 | ps_reg_03 | 9.806298e-02 | 3.916688e-02 | 0.043682 | 1.057116 | 0.086947 |
# Definio umbrales (a efectos de viz siguiente) y no de filtrado, en función de valores obtenidos:
gini_threshold= 0.01 # es (abs(gini))
iv_threshold = 0.01
perm_threshold = 0.01 # está · 10^4
vif_threshold = 5
ks_threshold = 0.1
# Muestro:
def highlight_values(val, column):
if column == 'Gini':
return 'background-color: #c8e6c9' if abs(val) >= gini_threshold else 'background-color: #ffcc80'
elif column == 'Information Value (IV)':
return 'background-color: #c8e6c9' if val >= iv_threshold else 'background-color: #ffcc80'
elif column == 'Permutation Importance':
return 'background-color: #c8e6c9' if val >= perm_threshold else 'background-color: #ffcc80'
elif column == 'VIF':
return 'background-color: #c8e6c9' if val <= vif_threshold else 'background-color: #ffcc80'
elif column == 'KS':
return 'background-color: #c8e6c9' if val >= ks_threshold else 'background-color: #ffcc80'
return ''
final_df[['Gini','Information Value (IV)','Permutation Importance', 'VIF', 'KS']] = final_df[['Gini', 'Information Value (IV)','Permutation Importance','VIF', 'KS']].round(5)
# Aplico estilos para viz:
styled_df = final_df.style.format(precision=5) \
.applymap(lambda val: highlight_values(val, 'Gini'), subset=['Gini']) \
.applymap(lambda val: highlight_values(val, 'Information Value (IV)'), subset=['Information Value (IV)']) \
.applymap(lambda val: highlight_values(val, 'Permutation Importance'), subset=['Permutation Importance']) \
# .applymap(lambda val: highlight_values(val, 'VIF'), subset=['VIF']) \
# .applymap(lambda val: highlight_values(val, 'KS'), subset=['KS'])
styled_df
C:\Users\U0124476\AppData\Local\Temp\1\ipykernel_32804\1172576962.py:22: FutureWarning: Styler.applymap has been deprecated. Use Styler.map instead. C:\Users\U0124476\AppData\Local\Temp\1\ipykernel_32804\1172576962.py:23: FutureWarning: Styler.applymap has been deprecated. Use Styler.map instead. C:\Users\U0124476\AppData\Local\Temp\1\ipykernel_32804\1172576962.py:24: FutureWarning: Styler.applymap has been deprecated. Use Styler.map instead.
| Feature | Gini | Information Value (IV) | Permutation Importance | VIF | KS | |
|---|---|---|---|---|---|---|
| 0 | ps_calc_01 | 0.00010 | 0.00068 | 0.00672 | nan | nan |
| 1 | ps_calc_02 | 0.00047 | 0.00049 | 0.01344 | nan | nan |
| 2 | ps_calc_03 | 0.00044 | 0.00036 | 0.01008 | nan | nan |
| 3 | ps_calc_04 | 0.00016 | 0.00015 | 0.00000 | nan | nan |
| 4 | ps_calc_05 | 0.00012 | 0.00029 | 0.00000 | nan | nan |
| 5 | ps_calc_06 | 0.00000 | 0.00095 | 0.02688 | nan | nan |
| 6 | ps_calc_07 | 0.00001 | 0.00051 | 0.01344 | nan | nan |
| 7 | ps_calc_08 | 0.00000 | 0.00041 | 0.01344 | nan | nan |
| 8 | ps_calc_09 | -0.00008 | 0.00030 | 0.00000 | nan | nan |
| 9 | ps_calc_10 | 0.00345 | 0.00046 | 0.02016 | 1.00001 | 0.00875 |
| 10 | ps_calc_11 | -0.00000 | 0.00139 | 0.02352 | nan | nan |
| 11 | ps_calc_12 | -0.00021 | 0.00058 | 0.00672 | nan | nan |
| 12 | ps_calc_13 | -0.00011 | 0.00025 | 0.02016 | nan | nan |
| 13 | ps_calc_14 | 0.00291 | 0.00032 | 0.00336 | 1.00000 | 0.00422 |
| 14 | ps_calc_15_bin | -0.00086 | 0.00001 | 0.00000 | nan | nan |
| 15 | ps_calc_16_bin | 0.00161 | 0.00001 | 0.00000 | nan | nan |
| 16 | ps_calc_17_bin | -0.00045 | 0.00000 | 0.00672 | nan | nan |
| 17 | ps_calc_18_bin | 0.00133 | 0.00001 | 0.01344 | nan | nan |
| 18 | ps_calc_19_bin | -0.00444 | 0.00009 | 0.00000 | nan | nan |
| 19 | ps_calc_20_bin | -0.00206 | 0.00003 | 0.00000 | nan | nan |
| 20 | ps_car_01_cat | -0.00032 | 0.03670 | 0.23521 | nan | nan |
| 21 | ps_car_02_cat | -0.06323 | 0.02523 | -0.00336 | nan | nan |
| 22 | ps_car_03_cat | 0.06276 | 0.01027 | 0.11425 | nan | nan |
| 23 | ps_car_04_cat | 0.00791 | 0.03496 | 0.08400 | nan | nan |
| 24 | ps_car_05_cat | 0.03309 | 0.00002 | 0.01008 | nan | nan |
| 25 | ps_car_06_cat | 0.00096 | 0.03382 | 0.02352 | nan | nan |
| 26 | ps_car_07_cat | -0.04448 | 0.00958 | 0.14449 | nan | nan |
| 27 | ps_car_08_cat | -0.04057 | 0.01088 | 0.00336 | nan | nan |
| 28 | ps_car_09_cat | 0.00685 | 0.01679 | 0.01680 | nan | nan |
| 29 | ps_car_10_cat | 0.00024 | 0.00003 | 0.00000 | nan | nan |
| 30 | ps_car_11 | -0.00440 | 0.01275 | 0.00000 | nan | nan |
| 31 | ps_car_11_cat | 0.02483 | 0.00639 | 0.09408 | 1.00395 | 0.03515 |
| 32 | ps_car_12 | 0.11031 | 0.03995 | 0.05712 | 2.21219 | 0.08375 |
| 33 | ps_car_13 | 0.15309 | 0.07207 | 0.10080 | 1.91331 | 0.11525 |
| 34 | ps_car_14 | 0.02395 | 0.01879 | 0.02688 | 1.41419 | 0.06123 |
| 35 | ps_car_15 | 0.00060 | 0.03006 | 0.01008 | nan | nan |
| 36 | ps_ind_01 | 0.00414 | 0.01177 | 0.00336 | nan | nan |
| 37 | ps_ind_02_cat | 0.00447 | 0.00108 | 0.23521 | nan | nan |
| 38 | ps_ind_03 | -0.00178 | 0.02880 | 0.06048 | nan | nan |
| 39 | ps_ind_04_cat | 0.02607 | 0.00291 | 0.36962 | nan | nan |
| 40 | ps_ind_05_cat | 0.00870 | 0.03000 | 0.33265 | nan | nan |
| 41 | ps_ind_06_bin | -0.08869 | 0.03459 | 0.06720 | nan | nan |
| 42 | ps_ind_07_bin | 0.07979 | 0.03082 | 0.08736 | nan | nan |
| 43 | ps_ind_08_bin | 0.02597 | 0.00466 | 0.05040 | nan | nan |
| 44 | ps_ind_09_bin | -0.01708 | 0.00200 | 0.02352 | nan | nan |
| 45 | ps_ind_10_bin | 0.00019 | 0.00008 | 0.00000 | nan | nan |
| 46 | ps_ind_11_bin | 0.00044 | 0.00010 | 0.00000 | nan | nan |
| 47 | ps_ind_12_bin | 0.00403 | 0.00147 | 0.00000 | nan | nan |
| 48 | ps_ind_13_bin | 0.00040 | 0.00015 | 0.00000 | nan | nan |
| 49 | ps_ind_14 | 0.00103 | 0.00146 | 0.00000 | nan | nan |
| 50 | ps_ind_15 | -0.00074 | 0.01612 | 0.04032 | nan | nan |
| 51 | ps_ind_16_bin | -0.07018 | 0.02113 | 0.08400 | nan | nan |
| 52 | ps_ind_17_bin | 0.06450 | 0.03289 | 0.11761 | nan | nan |
| 53 | ps_ind_18_bin | 0.00876 | 0.00058 | 0.00000 | nan | nan |
| 54 | ps_reg_01 | -0.00032 | 0.02400 | 0.05040 | nan | nan |
| 55 | ps_reg_02 | 0.00063 | 0.04186 | 0.04032 | nan | nan |
| 56 | ps_reg_03 | 0.09806 | 0.03917 | 0.04368 | 1.05712 | 0.08695 |
# Selección final - métricas agregadas y preparación para discretización:
final_df_reduced = final_df.copy()
final_df_reduced['Sum Gini + IV'] = final_df_reduced[['Gini', 'Information Value (IV)']].abs().sum(axis=1)
final_df_reduced['Sum Gini + IV + Perm'] = final_df_reduced[['Gini', 'Information Value (IV)', 'Permutation Importance']].abs().sum(axis=1)
# Min/Max para comparabildiad a efectos de tabla visualizada:
final_df_reduced['Sum Gini + IV Norm'] = (final_df_reduced['Sum Gini + IV'] - final_df_reduced['Sum Gini + IV'].min()) / \
(final_df_reduced['Sum Gini + IV'].max() - final_df_reduced['Sum Gini + IV'].min())
final_df_reduced['Sum Gini + IV + Perm Norm'] = (final_df_reduced['Sum Gini + IV + Perm'] - final_df_reduced['Sum Gini + IV + Perm'].min()) / \
(final_df_reduced['Sum Gini + IV + Perm'].max() - final_df_reduced['Sum Gini + IV + Perm'].min())
# Visualización:
columns_to_display = ['Feature', 'Gini', 'Information Value (IV)', 'Permutation Importance', 'Sum Gini + IV', 'Sum Gini + IV + Perm']
final_df_filtered = final_df_reduced[columns_to_display]
final_df_filtered = final_df_filtered.sort_values(by=['Sum Gini + IV', 'Sum Gini + IV + Perm'], ascending=False)
cmap = sns.light_palette("red", as_cmap=True)
styled_df_filtered = final_df_filtered.style.format(precision=5) \
.background_gradient(cmap=cmap, subset=['Sum Gini + IV', 'Sum Gini + IV + Perm'])
styled_df_filtered
| Feature | Gini | Information Value (IV) | Permutation Importance | Sum Gini + IV | Sum Gini + IV + Perm | |
|---|---|---|---|---|---|---|
| 33 | ps_car_13 | 0.15309 | 0.07207 | 0.10080 | 0.22516 | 0.32596 |
| 32 | ps_car_12 | 0.11031 | 0.03995 | 0.05712 | 0.15026 | 0.20738 |
| 56 | ps_reg_03 | 0.09806 | 0.03917 | 0.04368 | 0.13723 | 0.18091 |
| 41 | ps_ind_06_bin | -0.08869 | 0.03459 | 0.06720 | 0.12328 | 0.19048 |
| 42 | ps_ind_07_bin | 0.07979 | 0.03082 | 0.08736 | 0.11061 | 0.19797 |
| 52 | ps_ind_17_bin | 0.06450 | 0.03289 | 0.11761 | 0.09739 | 0.21500 |
| 51 | ps_ind_16_bin | -0.07018 | 0.02113 | 0.08400 | 0.09131 | 0.17531 |
| 21 | ps_car_02_cat | -0.06323 | 0.02523 | -0.00336 | 0.08846 | 0.09182 |
| 22 | ps_car_03_cat | 0.06276 | 0.01027 | 0.11425 | 0.07303 | 0.18728 |
| 26 | ps_car_07_cat | -0.04448 | 0.00958 | 0.14449 | 0.05406 | 0.19855 |
| 27 | ps_car_08_cat | -0.04057 | 0.01088 | 0.00336 | 0.05145 | 0.05481 |
| 23 | ps_car_04_cat | 0.00791 | 0.03496 | 0.08400 | 0.04287 | 0.12687 |
| 34 | ps_car_14 | 0.02395 | 0.01879 | 0.02688 | 0.04274 | 0.06962 |
| 55 | ps_reg_02 | 0.00063 | 0.04186 | 0.04032 | 0.04249 | 0.08281 |
| 40 | ps_ind_05_cat | 0.00870 | 0.03000 | 0.33265 | 0.03870 | 0.37135 |
| 20 | ps_car_01_cat | -0.00032 | 0.03670 | 0.23521 | 0.03702 | 0.27223 |
| 25 | ps_car_06_cat | 0.00096 | 0.03382 | 0.02352 | 0.03478 | 0.05830 |
| 24 | ps_car_05_cat | 0.03309 | 0.00002 | 0.01008 | 0.03311 | 0.04319 |
| 31 | ps_car_11_cat | 0.02483 | 0.00639 | 0.09408 | 0.03122 | 0.12530 |
| 35 | ps_car_15 | 0.00060 | 0.03006 | 0.01008 | 0.03066 | 0.04074 |
| 43 | ps_ind_08_bin | 0.02597 | 0.00466 | 0.05040 | 0.03063 | 0.08103 |
| 38 | ps_ind_03 | -0.00178 | 0.02880 | 0.06048 | 0.03058 | 0.09106 |
| 39 | ps_ind_04_cat | 0.02607 | 0.00291 | 0.36962 | 0.02898 | 0.39860 |
| 54 | ps_reg_01 | -0.00032 | 0.02400 | 0.05040 | 0.02432 | 0.07472 |
| 28 | ps_car_09_cat | 0.00685 | 0.01679 | 0.01680 | 0.02364 | 0.04044 |
| 44 | ps_ind_09_bin | -0.01708 | 0.00200 | 0.02352 | 0.01908 | 0.04260 |
| 30 | ps_car_11 | -0.00440 | 0.01275 | 0.00000 | 0.01715 | 0.01715 |
| 50 | ps_ind_15 | -0.00074 | 0.01612 | 0.04032 | 0.01686 | 0.05718 |
| 36 | ps_ind_01 | 0.00414 | 0.01177 | 0.00336 | 0.01591 | 0.01927 |
| 53 | ps_ind_18_bin | 0.00876 | 0.00058 | 0.00000 | 0.00934 | 0.00934 |
| 37 | ps_ind_02_cat | 0.00447 | 0.00108 | 0.23521 | 0.00555 | 0.24076 |
| 47 | ps_ind_12_bin | 0.00403 | 0.00147 | 0.00000 | 0.00550 | 0.00550 |
| 18 | ps_calc_19_bin | -0.00444 | 0.00009 | 0.00000 | 0.00453 | 0.00453 |
| 9 | ps_calc_10 | 0.00345 | 0.00046 | 0.02016 | 0.00391 | 0.02407 |
| 13 | ps_calc_14 | 0.00291 | 0.00032 | 0.00336 | 0.00323 | 0.00659 |
| 49 | ps_ind_14 | 0.00103 | 0.00146 | 0.00000 | 0.00249 | 0.00249 |
| 19 | ps_calc_20_bin | -0.00206 | 0.00003 | 0.00000 | 0.00209 | 0.00209 |
| 15 | ps_calc_16_bin | 0.00161 | 0.00001 | 0.00000 | 0.00162 | 0.00162 |
| 10 | ps_calc_11 | -0.00000 | 0.00139 | 0.02352 | 0.00139 | 0.02491 |
| 17 | ps_calc_18_bin | 0.00133 | 0.00001 | 0.01344 | 0.00134 | 0.01478 |
| 1 | ps_calc_02 | 0.00047 | 0.00049 | 0.01344 | 0.00096 | 0.01440 |
| 5 | ps_calc_06 | 0.00000 | 0.00095 | 0.02688 | 0.00095 | 0.02783 |
| 14 | ps_calc_15_bin | -0.00086 | 0.00001 | 0.00000 | 0.00087 | 0.00087 |
| 2 | ps_calc_03 | 0.00044 | 0.00036 | 0.01008 | 0.00080 | 0.01088 |
| 11 | ps_calc_12 | -0.00021 | 0.00058 | 0.00672 | 0.00079 | 0.00751 |
| 0 | ps_calc_01 | 0.00010 | 0.00068 | 0.00672 | 0.00078 | 0.00750 |
| 48 | ps_ind_13_bin | 0.00040 | 0.00015 | 0.00000 | 0.00055 | 0.00055 |
| 46 | ps_ind_11_bin | 0.00044 | 0.00010 | 0.00000 | 0.00054 | 0.00054 |
| 6 | ps_calc_07 | 0.00001 | 0.00051 | 0.01344 | 0.00052 | 0.01396 |
| 16 | ps_calc_17_bin | -0.00045 | 0.00000 | 0.00672 | 0.00045 | 0.00717 |
| 7 | ps_calc_08 | 0.00000 | 0.00041 | 0.01344 | 0.00041 | 0.01385 |
| 4 | ps_calc_05 | 0.00012 | 0.00029 | 0.00000 | 0.00041 | 0.00041 |
| 8 | ps_calc_09 | -0.00008 | 0.00030 | 0.00000 | 0.00038 | 0.00038 |
| 12 | ps_calc_13 | -0.00011 | 0.00025 | 0.02016 | 0.00036 | 0.02052 |
| 3 | ps_calc_04 | 0.00016 | 0.00015 | 0.00000 | 0.00031 | 0.00031 |
| 29 | ps_car_10_cat | 0.00024 | 0.00003 | 0.00000 | 0.00027 | 0.00027 |
| 45 | ps_ind_10_bin | 0.00019 | 0.00008 | 0.00000 | 0.00027 | 0.00027 |
# Observo relación entre las dos métricas agregadas generadas:
plt.figure(figsize=(20, 6))
sns.regplot(
x=final_df_reduced['Sum Gini + IV'],
y=final_df_reduced['Sum Gini + IV + Perm'],
scatter_kws={"alpha":1},
line_kws={"color": "red"}
)
plt.title("Relación entre 'Sum Gini + IV' y 'Sum Gini + IV + Perm'")
plt.xlabel("Sum Gini + IV")
plt.ylabel("Sum Gini + IV + Perm")
plt.show()
Criterio final: Dicotomización óptima del ranking mediante el Método de Otsu
El método de Otsu es una técnica de "umbralización automática" que permite dividir una variable continua en dos grupos al determinar un umbral óptimo que permite dividir la distribución en 2. Se basa en principios estadísticos sólidos de minimización de varianza intra-clase o, equivalentemente, maximización de varianza inter-clase. Si bien su uso original fue previsto en Computing Vison, en el ámbito de la visión por computador, este método ha demostrado ser igualmente útil en el análisis de datos tabulares, incluyendo selección de variables.
Fundamento matemático detrás del método:
Dado un conjunto unidimensional de datos \( X = \{x_1, x_2, ..., x_N\} \), representado mediante un histograma de probabilidad, el objetivo es encontrar un umbral \( t^* \) que:
- Minimice la varianza intra-clase \( \sigma_w^2(t) \)
- O, equivalentemente, maximice la varianza inter-clase \( \sigma_b^2(t) \)
1. Probabilidades de clase acumuladas
P₀(t) = Σ₀ᵗ pᵢ, P₁(t) = Σₜ₊₁ᴸ₋₁ pᵢ pᵢ = hᵢ / N
donde hᵢ es la frecuencia del bin i, y N el número total de observaciones.
2. Medias de clase y media total
μ₀(t) = Σ₀ᵗ i·pᵢ / P₀(t), μ₁(t) = Σₜ₊₁ᴸ₋₁ i·pᵢ / P₁(t) μ_T = Σ₀ᴸ₋₁ i·pᵢ
3. Varianza intra-clase (minimización)
σ_w²(t) = P₀(t)·σ₀²(t) + P₁(t)·σ₁²(t)
El umbral óptimo se define como:
t* = argminₜ σ_w²(t)
4. Varianza inter-clase (maximización)
σ_b²(t) = P₀(t)·P₁(t)·(μ₀(t) - μ₁(t))² t* = argmaxₜ σ_b²(t)
Ambas formulaciones son equivalentes ya que:
σ_T² = σ_w² + σ_b²
Interpretación y aplicación práctica
El método de Otsu actúa como un algoritmo de clustering binario unidimensional, encontrando un punto de corte natural en la distribución de la variable. Su utilidad se extiende más allá del procesamiento de imágenes:
- Segmentación de imágenes: umbralización de pixeles
- Selección de variables: dicotomización de scores continuos como Gini + IV en nuestro caso particular
- Detección de anomalías: separación entre comportamientos normales y anómalos
Justificación de uso en este análisis
En este trabajo, el método de Otsu se emplea para determinar un punto de corte objetivo sobre la variable agregada Gini + IV. Esta variable combina la capacidad discriminativa con la estabilidad informativa de cada predictor, y su distribución presenta un perfil adecuado para ser dicotomizada de forma no arbitraria.
Frente a métodos heurísticos o basados en cuantiles fijos, Otsu ofrece una solución optimizada y reproducible para separar automáticamente el conjunto de variables entre "relevantes" y "no relevantes", asegurando una segmentación basada en la estructura estadística interna de los datos.
# OTSU:
# Winsorization para mitigar outliers;
values = final_df_filtered["Sum Gini + IV"].dropna()
values_trimmed = values[values < np.percentile(values, 95)].to_numpy()
# Calcular el umbral de Otsu desde threshold_otsu:
otsu_threshold = threshold_otsu(values_trimmed)
print(f"Umbral de Otsu por threshold_otsu desde Skimage: {otsu_threshold}")
# Cálculo manual de Otsu en base a definición funcional (ratificación):
# ---------------------------------------------------------------------
# Binarizo en 20 bins (esto puede llevar a ligeras diferencias):
hist, bin_edges = np.histogram(values_trimmed, bins=20)
bin_mids = (bin_edges[:-1] + bin_edges[1:]) / 2
# Prob de cada clase:
w1 = np.cumsum(hist)
w2 = np.cumsum(hist[::-1])[::-1]
# mediana acumulada:
media1 = np.cumsum(hist * bin_mids) / w1
media2 = (np.cumsum((hist * bin_mids)[::-1]) / w2[::-1])[::-1]
# varianza inter-clase
var_inter = (w1[:-1] * w2[1:] * (media1[:-1] - media2[1:])**2)
# índice con máxima varianza inter-clase:
id_max_var = np.argmax(var_inter)
opt_thr = bin_mids[id_max_var]
print(f"Umbral de Otsu cuantificado manualmente: {opt_thr}")
# Visualización gráfica:
color =(0,0.5,0.7,0.2)
plt.figure(figsize=(20, 10))
plt.hist(values_trimmed, bins=20, color=color, alpha=0.2, label="Distribución de Sum Gini + IV")
plt.axvline(opt_thr, color="red", linestyle="--", linewidth=2, label=f"Umbral óptimo: {opt_thr:.5f}")
plt.xlabel("Sum Gini + IV")
plt.ylabel("Frecuencia")
plt.title("Determinación del Punto de Corte Óptimo")
plt.legend()
plt.grid(axis="y", linestyle="--", alpha=0.6)
plt.show()
Umbral de Otsu por threshold_otsu desde Skimage: 0.042794941406249995 Umbral de Otsu cuantificado manualmente: 0.04024825
# Visualización:
df = pd.DataFrame(final_df_filtered)
color_gini = "blue"
color_iv = "green"
color_perm = "purple"
color_bar = (0, 0.5, 0.7, 0.2)
plt.figure(figsize=(20, 8))
plt.plot(df["Feature"], abs(df["Gini"]), label="Gini", marker="o", color=color_gini, linestyle="-", linewidth=2)
plt.plot(df["Feature"], abs(df["Information Value (IV)"]), label="Information Value (IV)", marker="s", color=color_iv, linestyle="--", linewidth=2)
plt.plot(df["Feature"], abs(df["Permutation Importance"]), label="Permutation Importance", marker="^", color=color_perm, linestyle="-.", linewidth=2)
plt.bar(df["Feature"], df["Sum Gini + IV"], label="Sum Gini + IV", color=color_bar)
plt.axhline(y=opt_thr, color='red', linestyle='--', linewidth=2, label=f"Umbral Otsu: {opt_thr:.5f}")
plt.xlabel("Feature")
plt.ylabel("Value")
plt.title("Feature Importance Metrics")
plt.xticks(rotation=90)
plt.legend()
plt.grid(True)
plt.show()
Conjunto de datos final: Short List
df_final_train = final_df_filtered[final_df_filtered['Sum Gini + IV'] >= opt_thr]
df_final_train
| Feature | Gini | Information Value (IV) | Permutation Importance | Sum Gini + IV | Sum Gini + IV + Perm | |
|---|---|---|---|---|---|---|
| 33 | ps_car_13 | 0.15309 | 0.07207 | 0.10080 | 0.22516 | 0.32596 |
| 32 | ps_car_12 | 0.11031 | 0.03995 | 0.05712 | 0.15026 | 0.20738 |
| 56 | ps_reg_03 | 0.09806 | 0.03917 | 0.04368 | 0.13723 | 0.18091 |
| 41 | ps_ind_06_bin | -0.08869 | 0.03459 | 0.06720 | 0.12328 | 0.19048 |
| 42 | ps_ind_07_bin | 0.07979 | 0.03082 | 0.08736 | 0.11061 | 0.19797 |
| 52 | ps_ind_17_bin | 0.06450 | 0.03289 | 0.11761 | 0.09739 | 0.21500 |
| 51 | ps_ind_16_bin | -0.07018 | 0.02113 | 0.08400 | 0.09131 | 0.17531 |
| 21 | ps_car_02_cat | -0.06323 | 0.02523 | -0.00336 | 0.08846 | 0.09182 |
| 22 | ps_car_03_cat | 0.06276 | 0.01027 | 0.11425 | 0.07303 | 0.18728 |
| 26 | ps_car_07_cat | -0.04448 | 0.00958 | 0.14449 | 0.05406 | 0.19855 |
| 27 | ps_car_08_cat | -0.04057 | 0.01088 | 0.00336 | 0.05145 | 0.05481 |
| 23 | ps_car_04_cat | 0.00791 | 0.03496 | 0.08400 | 0.04287 | 0.12687 |
| 34 | ps_car_14 | 0.02395 | 0.01879 | 0.02688 | 0.04274 | 0.06962 |
| 55 | ps_reg_02 | 0.00063 | 0.04186 | 0.04032 | 0.04249 | 0.08281 |
# Extracción de la lista de valores únicos en la columna "Features" y añadir target e id, a efectos de identificación:
features_list = ["id", "target"] + df_final_train["Feature"].unique().tolist()
# Dataset final para planteamiento analítico - Modelización de target:
train_filtered = train[features_list]
train_filtered.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 595212 entries, 0 to 595211 Data columns (total 16 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 id 595212 non-null int64 1 target 595212 non-null int64 2 ps_car_13 595212 non-null float64 3 ps_car_12 595212 non-null float64 4 ps_reg_03 595212 non-null float64 5 ps_ind_06_bin 595212 non-null int64 6 ps_ind_07_bin 595212 non-null int64 7 ps_ind_17_bin 595212 non-null int64 8 ps_ind_16_bin 595212 non-null int64 9 ps_car_02_cat 595212 non-null int64 10 ps_car_03_cat 595212 non-null int64 11 ps_car_07_cat 595212 non-null int64 12 ps_car_08_cat 595212 non-null int64 13 ps_car_04_cat 595212 non-null int64 14 ps_car_14 595212 non-null float64 15 ps_reg_02 595212 non-null float64 dtypes: float64(5), int64(11) memory usage: 72.7 MB
train_filtered.head()
| id | target | ps_car_13 | ps_car_12 | ps_reg_03 | ps_ind_06_bin | ps_ind_07_bin | ps_ind_17_bin | ps_ind_16_bin | ps_car_02_cat | ps_car_03_cat | ps_car_07_cat | ps_car_08_cat | ps_car_04_cat | ps_car_14 | ps_reg_02 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 7 | 0 | 0.883679 | 0.404717 | -0.464869 | 0 | 1 | 1 | 0 | 1 | -1 | 1 | 0 | 0 | -0.123259 | 0.2 |
| 1 | 9 | 0 | 0.618817 | -0.868016 | -0.310186 | 0 | 0 | 0 | 0 | 1 | -1 | 1 | 1 | 0 | 0.454249 | 0.4 |
| 2 | 13 | 0 | 0.641586 | -0.868016 | 0.010037 | 0 | 0 | 0 | 1 | 1 | -1 | 1 | 1 | 0 | -0.764710 | 0.0 |
| 3 | 16 | 0 | 0.542949 | -0.191865 | -1.107059 | 1 | 0 | 0 | 1 | 1 | 0 | 1 | 1 | 0 | -1.862552 | 0.2 |
| 4 | 17 | 0 | 0.565832 | -1.831224 | 0.332574 | 1 | 0 | 0 | 1 | 1 | -1 | 1 | 1 | 0 | -0.316330 | 0.6 |
# Tipología del subconjunto siguiendo mismo criterio que sobre el conjunto total:
target='target'
binary_vars, categorical_vars, continuous_vars = classify_variables(train_filtered, target)
print(f"Variables binarias: {binary_vars}\n")
print(f"Variables categóricas: {categorical_vars}\n")
print(f"Variables continuas: {continuous_vars}\n")
Variables binarias: ['ps_ind_06_bin', 'ps_ind_07_bin', 'ps_ind_17_bin', 'ps_ind_16_bin', 'ps_car_08_cat'] Variables categóricas: ['ps_car_02_cat', 'ps_car_03_cat', 'ps_car_07_cat', 'ps_car_04_cat', 'ps_reg_02'] Variables continuas: ['ps_car_13', 'ps_car_12', 'ps_reg_03', 'ps_car_14']
# Viz de variables, previa antrada a modelo:
fig, ax = plt.subplots(figsize=(16, 8))
for col in ['ps_reg_03', 'ps_car_12', 'ps_car_14', 'ps_car_13']:
sns.kdeplot(train_filtered[col].dropna(), label=col, fill=True, alpha=0.4)
ax.set_title("Distribución de Variables Previas a Entrada al Modelo", fontsize=14)
ax.set_xlabel("Valor")
ax.set_ylabel("Densidad")
ax.legend()
plt.show()
# Normalizo con el Imputer que ya había generado para normalizar las otras cuant:
train_filtered[['ps_car_13']] = normal_dist.fit_transform(train_filtered[['ps_car_13']])
# Visualizo de nuevo:
fig, ax = plt.subplots(figsize=(16, 8))
for col in ['ps_reg_03', 'ps_car_12', 'ps_car_14', 'ps_car_13']:
sns.kdeplot(train_filtered[col].dropna(), label=col, fill=True, alpha=0.4)
ax.set_title("Distribución de Variables Previas a Entrada al Modelo", fontsize=14)
ax.set_xlabel("Valor")
ax.set_ylabel("Densidad")
ax.legend()
plt.show()
C:\Users\U0124476\AppData\Local\Temp\1\ipykernel_32804\800433536.py:2: SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame. Try using .loc[row_indexer,col_indexer] = value instead See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
4. Modelización
Tal y como hemos seguido durante todo el caso de uso, a lo largo de la línea lógica de desarrollo y explotación del conocimiento del dataset, el planteamiento de desarrollo de una solución cuantitativa también requiere de especial atención a causa del desbalanceo natural del conjunto de datos de Porto Seguro.
Para hacer frente a la modelización de un conjunto de datos preprocesado, pero desbalanceado, es importante tener en cuenta su impacto tanto en la propia modelización (desde la solución cuantitativa adoptada, hasta la hiperparametría para ajustar el problema), y sobre todo, en las métricas de error que realmente nos den explicabilidad acerca de la bondad de ajuste de nuestro clasificador.
A continuación, se va a proceder con:
- [1] Una explicación de cómo se debe interpretar el resultado del clasificador en un caso desbalanceado.
- [2] Un detalle de las soluciones cuantitativas que se van a implementar de cara a su futuro contraste sobre el caso de uso, y justificadas por la situación actual del estado del arte en materia.
Métricas de error para Datos Desbalanceados sobre target binario (0–1 de riesgo de siniestralidad)
El procedimiento nominal en la evaluación del error de un planteamiento de clasificación binaria, como es nuestro caso de uso, pasa por la evaluación lógica de la matriz de confusión. Esta matriz nos permite obtener mucha información acerca del ajuste de nuestro clasificador, en función de cómo este se haya desempeñado (predicción), en contraste con la realidad (actual/real).
Asimismo, es necesario tener en cuenta que la matriz de confusión es simplemente un snapshot del confidence/cut-off óptimo que cada solución cuantitativa ha adoptado. Por esa razón, además de esta matriz, se articulan métricas que consideran la totalidad del rango de confianza (0–1), en lo relativo a su clasificación.
Estas métricas —como ROC-AUC o PR-AUC— nos proporcionan una imagen completa sobre el desempeño del modelo, a partir del análisis de la distribución de métricas extraídas de la matriz de confusión, pero observadas a lo largo de todos los posibles valores de corte.
A continuación, se especifica cómo debe interpretarse el error de un clasificador binario como el que tenemos en este problema, para un caso de uso desbalanceado como es el riesgo de siniestralidad.
Idiosincrasia de la Matriz de Confusión vs. Métricas AUC como elemento clave
Uno de los aspectos más críticos —y frecuentemente que suelen ser malinterpretados— en la evaluación de modelos de clasificación binaria tanto sobre datasets desbalanceados como a nivel más genérico, es el carácter **puntual y condicional** de la matriz de confusión frente al carácter **global y marginal** de métricas como ROC-AUC y PR-AUC.
Como se cita anteriormente, la matriz de confusión representa una única "snapshot" del rendimiento del modelo para un umbral de decisión (cut-off) específico, normalmente atribuido como el óptimo, ya que es normalmente cuando se apsa a mostrar la matriz de confusión. Dado un score de probabilidad generado por el modelo para cada observación, se fija un umbral (por ejemplo, 0.5), y a partir de este se decide si una observación pertenece a la clase positiva o negativa. Es decir:
ŷi = 1 si P(y=1 | xi) ≥ τ
ŷi = 0 si P(y=1 | xi) < τ
Este valor de corte define completamente la matriz de confusión (TP, FP, TN, FN) y por tanto todas las métricas asociadas a ella como precisión, recall, especificidad, F1-score, etc. Sin embargo, esta visión es parcial, ya que solo representa el rendimiento en ese único punto de decisión, y no captura el comportamiento del modelo ante distintas decisiones en función del umbral de corte.
En términos más estadísticos, la matriz de confusión es **condicional al cut-off elegido**, mientras que ROC-AUC y PR-AUC son **incondicionales**, ya que consideran todo el soporte del score de clasificación.
Una vez tenemos claro esto:
Las métricas ROC-AUC y PR-AUC evalúan la capacidad discriminante del modelo sobre todos los posibles umbrales τ ∈ [0,1]. En lugar de tomar una decisión de clase directa, estas métricas utilizan los scores continuos generados por el modelo para construir curvas que integran la variación del rendimiento:
- ROC-AUC: Área bajo la curva ROC (TPR vs. FPR) al variar el umbral τ.
- PR-AUC: Área bajo la curva Precision vs. Recall al variar el umbral τ.
Estas métricas no requieren fijar ningún umbral, por lo que ofrecen una evaluación agregada y robusta de la capacidad predictiva del modelo, independientemente del corte elegido.
Interpretación clave: Mientras que una matriz de confusión responde a “¿qué tal clasifica el modelo con un τ fijo?”, el AUC responde a “¿cuánto mejor es capaz el modelo de clasificar mejor que una predicción aleatoria, considerando todos los posibles τ?”.
Integración con los resultados soluciones predictivas adoptadas:
Para tener una visión holística y contrastar soluciones analíticas como las que se propondran en este apartado de forma robusta, las métricas AUC (tanto roc_auc_score como average_precision_score) deben ser el criterio principal. A partir de estas, se puede luego descender al detalle puntual de una matriz de confusión para interpretar casos concretos y calcular métricas más aplicables a negocio (por ejemplo, tasa de siniestros correctamente identificados a un coste aceptable de falsos positivos).
Precision y Recall como métricas clave en un clasificador desbalanceado
Sobre un caso de detección de siniestros como el nuestro( Aprox. un 4% clase positiva)—, no todas las métricas tienen el mismo poder explicativo. En particular, las métricas que ponderan por igual ambas clases (como accuracy) pueden llevar a interpretaciones erróneas sobre el correcto rendimiento que realmente está realizando el modelo.
Por ello, se hace necesario desplazar el foco hacia métricas que reflejen con mayor fidelidad la capacidad del modelo de identificar correctamente la clase minoritaria (siniestros), sin que la clase mayoritaria (no siniestros) distorsione la evaluación.
1. En la matriz de confusión: el sesgo de la clase mayoritaria
Supongamos que un modelo predice todo como clase negativa. En un dataset con 96% de clase 0 (no siniestro), se obtendría un accuracy del 96% sin haber detectado ningún siniestro (TP = 0), lo cual es completamente inútil desde el punto de vista del negocio.
En estas situaciones, precision y recall aplicadas sobre la clase positiva son métricas fundamentales:
- Precision: ¿Qué proporción de las predicciones positivas son realmente siniestros?
- Recall (TPR): ¿Qué proporción de siniestros reales ha sido detectada?
El compromiso entre ambas se sintetiza en el F1-score, que penaliza fuertemente cuando una de las dos métricas es baja
2. En la curva AUC: PR-AUC vs. ROC-AUC
La métrica ROC-AUC, pondera por igual la tasa de verdaderos positivos (TPR) y la tasa de falsos positivos (FPR). Esta simetría puede ser controvertida cuando la clase positiva es escasa, ya que incluso una mejora significativa en la detección de siniestros puede no reflejarse adecuadamente en el valor del ROC-AUC. En ese contexto, aunque ROC-AUC nos proporciona de manera generalizada la separación de ambas clases en el clasificador (poder discriminante), y se establece como principal métrica objetivo dado el problema del clasificador binario, la PR-AUC (área bajo la curva Precision-Recall) en nuestro caso también nos aporta información relevante ya que:
- Se centra exclusivamente en la clase positiva (siniestros).
- Ignora la clase mayoritaria, evitando el sesgo del desequilibrio.
- Es más sensible a mejoras reales en la detección de eventos poco frecuentes.
Consideración operativa
De este modo, los modelos se contrastarán según como diferencien las clases en el planteamiento del clasificador ante el evento binario de siniesto/no siniestro. Esto pasa por visualizar el área bajo la curva ROC, la cual es linealmente extrapolable al íncide de Gini, basado en la curva de Lorentz (y visualizado más arriba), siendo esta la métrica usada en los planteamientos analíticos en estado del arte en materia de cusntificación de riesgos bajo enevsnto binarios. No obstante, dada la naturaleza de estos eventos, visualizaremos un segundo eje, a nivel de métricas generales del modelo, como es el valor PR-AUC, como eje secundario en nuestro problema. Finalmente, un "mejor modelo", podrá designarse en base a la observación de ambas métricas, pues nos dotan de información global sobre como ha performado cada modelo. Sedrá en este punto dodne, tras seleccionar un mejor modelo en base a su poder discriminante, se pase a visualizar, "su interior". Hablamos de la matriz de confusión, para los distintos cut-off, pues será esencial entender como el clasificador separa a los eventos en función del umbral, y ser capaces de justificar qué umbral o rango de umbrales nos podría interesar para nuestro problema específico de detección del riesgo asegurador, ya que es justo en ese momento donde nos separaremos más de una solución "técnica" y pasaremos a tomar decisiones en función de nuestros intereses de negocio. Debemos tener en cuenta como el poder diferenciador/discriminante que nos proporciona ROC-AUC/PR-AUC, es invariante en función del cutt-off, ya que este como bien hemos comentado se establece sobre todo el espectro posible de esto. Es por eso, que cuando seleccionemos el mejor modelo, a nivel técnico ya sabemos como este performa/distingue las clases, y únicamente tenemos capacidad sobre como adaptarlo a nuestro planteamiento específico desde un punto de vista más funcional, de negocio. Esto apsa por decirir un umbral óptimo y por calibrar el modelo. Este último punto, si bien se verá en detalle, es otra herramienta que nos permite pasar de un evento binario (siniestro/no siniestro) y de la capacidad de tener un modelo que diferencie este evento, a un planteamiento basado en probabilidades de ocurrencia del evento. Este paso es esencial, ya que pasamos de la detección del siniestro, a la detección del riesgo siniestral, siendo este el foco específico de nuestro caso de uso.
Modelos e Hiperparámetros
Un punto crítico cuando se tiene a disposición un conjunto de datos tratado/analizado previamente con el que se quiere obtener valor, es la selección de soluciones analíticas que se ajusten a nuestro caso de uso. Es precisamente en este punto donde entra de manera impactada el Estado del Arte en Materia de Modelización del riesgo (no solo de siniestralidad, sino extensible a casos como el riesgo de crédito).
El planteamiento es básico, y sigue una lógica irrefutable en la consideración de los algoritmos que se aplican en el presente caso de uso:
- [1] Solución analítica adaptada por la industria durante los últimos 30 años en materia de modelización del riesgo → Regresión logística.
- [2] Estado del arte actual: adopción de metodologías basadas en árboles de clasificación con Boosting, por su elevada capacidad de explicabilidad con métodos como SHAP, LIME o PDP y su habilidad para modelizar patrones no lineales.
- [3] Solución analítica innovadora, nunca antes planteada en problemas con carácter regulatorio, basada en redes neuronales (ANNs) y optimizada para datos tabulares.
A partir de aquí, la exploración de cada solución analítica pasa por entender el rango de hiperparametría que podemos ajustar antes del entrenamiento para adaptar la solución a nuestro caso de uso específico. Es importante resaltar que estos hiperparámetros deben considerar siempre:
- Consciencia situacional del problema desbalanceado que pretendemos solucionar
- Regularización
1️. Regresión Logística (LogisticRegression)
| Hiperparámetro | Explicación | Rango |
|---|---|---|
C | Control de capacidad/fuerza de regularización (valores bajos = más penalización) | 0.01 – 10 |
class_weight | Ajuste del peso en clases desbalanceadas | "balanced" |
solver | Algoritmo numérico de optimización | ["lbfgs", "liblinear"] |
max_iter | Iteraciones máximas en la optimización | – |
model.fit | Sin métricas internas: la evaluación se realiza externamente | – |
2️. XGBoost (XGBClassifier)
| Hiperparámetro | Explicación | Rango |
|---|---|---|
n_estimators | Número de árboles | 50 – 500 |
max_depth | Profundidad máxima | 3 – 12 |
learning_rate | Tasa de aprendizaje | 0.005 – 0.3 |
scale_pos_weight | Peso de la clase positiva según el desbalance | Calculado previamente |
subsample | Fracción de muestras por árbol | 0.6 – 1.0 |
colsample_bytree | Fracción de variables por árbol | 0.6 – 1.0 |
gamma | Penalización de complejidad | 0 – 5 |
reg_alpha | Regularización L1 | 0 – 1 |
reg_lambda | Regularización L2 | 0 – 1 |
model.fit[eval_set] | Conjunto de validación [(X_test, y_test)] | – |
model.fit[eval_metric] | Métrica a monitorizar | – |
model.fit[early_stopping_rounds] | Detención anticipada tras 50 iteraciones sin mejora | – |
3️. LightGBM (LGBMClassifier)
| Hiperparámetro | Explicación | Rango |
|---|---|---|
num_leaves | Número máximo de hojas | 10 – 100 |
learning_rate | Tasa de aprendizaje | 0.005 – 0.3 |
n_estimators | Número de árboles | 50 – 500 |
is_unbalance | Compensación automática de clases | True |
feature_fraction | Fracción de variables por iteración | 0.6 – 1.0 |
lambda_l1 | Regularización L1 | 0.0 – 1.0 |
lambda_l2 | Regularización L2 | 0.0 – 1.0 |
model.fit[eval_set] | Conjunto de validación [(X_test, y_test)] | – |
model.fit[eval_metric] | Métrica de evaluación | – |
model.fit[objective] | Binary: obligatorio para clasificación binaria | – |
4️. TabNet (TabNetClassifier)
| Hiperparámetro | Explicación | Rango |
|---|---|---|
n_d, n_a | Dimensión de decisión y atención | 8 – 64 |
n_steps | Pasos de decisión | 3 – 10 |
gamma | Penalización por complejidad | 1.0 – 2.0 |
lambda_sparse | Regularización L1 para sparsity | 0.001 – 0.1 |
momentum | Suavizado en actualizaciones | 0.02 – 0.98 |
model.fit[eval_set] | Requiere arrays NumPy [(X_test, y_test)] | – |
model.fit[eval_metric] | Métrica para monitorizar mejora | – |
model.fit[patience] | Parada temprana tras N iteraciones sin mejora | – |
model.fit[max_epochs] | Número máximo de épocas | – |
model.fit[batch_size] | Tamaño del batch | – |
model.fit[virtual_batch_size] | Batch virtual para normalización | – |
Diferencias clave entre TabNet y soluciones Boosting
| Característica | TabNet | XGBoost / LightGBM |
|---|---|---|
| Enfoque | Red neuronal con atención | Árboles de boosting |
| Selección de variables | Automática | Manual |
| Valores nulos | No requiere imputación | Requiere imputación |
| Aprendizaje | Pasos de atención progresivos | Árboles secuenciales |
| Coste computacional | Alto | Moderado |
| Tamaño de dataset | Grande (miles o millones) | Cualquier tamaño |
Tratamiento de los conjuntos de datos:
Procedimiento de Preparación del Dataset
• Objetivo: Dividir el conjunto de datos en tres partes: entrenamiento, validación y test.
• Procedimiento: Se eliminan la columna target y la columna id para definir las variables predictoras (X) y la variable objetivo (y).
Se utiliza train_test_split para realizar una primera separación en dos partes: un conjunto de entrenamiento/validación (80%) y un conjunto de test (20%), asegurando el balance de clases mediante el parámetro stratify=y.
Posteriormente, el conjunto de entrenamiento/validación se divide de nuevo en entrenamiento (80% del 80%) y validación (20% del 80%).
• Imputación de Valores Faltantes: Se aplica SimpleImputer para reemplazar los valores faltantes en las variables categóricas con un valor constante (por ejemplo, -1).
• Codificación de Variables Categóricas: Se utiliza OneHotEncoder para transformar las variables categóricas en un formato numérico compatible con los algoritmos de aprendizaje automático.
• Preparación de Variables Binarias y Continuas:
– Las variables binarias se mantienen sin transformaciones adicionales.
– Las variables continuas, que ya están escaladas, se utilizan directamente.
Todas las transformaciones anteriores se combinan para generar el conjunto final de datos procesados, integrando las variables categóricas codificadas, las binarias originales y las continuas normalizadas.
Se comprueba que la proporción de clases (target = 0/1) se haya mantenido en cada conjunto resultante: entrenamiento, validación y test.
X = train_filtered.drop(columns=["target", "id"]) # Quitar target e ID
y = train_filtered["target"]
# División en train_val y test (80/20) el conjunto total:
X_train_val, X_test, y_train_val, y_test = train_test_split(X, y, test_size=0.2, random_state=123, stratify=y)
# División del conjunto de entrenamiento en train y validación (80/20 de train_val) sobre este mismo:
X_train, X_val, y_train, y_val = train_test_split(X_train_val, y_train_val, test_size=0.2, random_state=123, stratify=y_train_val)
# PREPROCESAMIENTO
# --------------------------------------------------------------------------------
# Imputación de valores en categóricas con -1
imputer_cat = SimpleImputer(strategy="constant", fill_value=-1)
X_train[categorical_vars] = imputer_cat.fit_transform(X_train[categorical_vars])
X_val[categorical_vars] = imputer_cat.transform(X_val[categorical_vars])
X_test[categorical_vars] = imputer_cat.transform(X_test[categorical_vars])
# One-Hot Encoding para variables categóricas
encoder = OneHotEncoder(handle_unknown='ignore', sparse_output=False)
X_train_cat = encoder.fit_transform(X_train[categorical_vars])
X_val_cat = encoder.transform(X_val[categorical_vars])
X_test_cat = encoder.transform(X_test[categorical_vars])
# Variables binarias (se quedan igual)
X_train_bin = X_train[binary_vars].values
X_val_bin = X_val[binary_vars].values
X_test_bin = X_test[binary_vars].values
# Variables continuas (ya están escaladas, se dejan igual)
X_train_quant = X_train[continuous_vars].values
X_val_quant = X_val[continuous_vars].values
X_test_quant = X_test[continuous_vars].values
# Concatenación de todas las transformaciones para cada conjunto
X_train_processed = np.hstack([X_train_cat, X_train_bin, X_train_quant])
X_val_processed = np.hstack([X_val_cat, X_val_bin, X_val_quant])
X_test_processed = np.hstack([X_test_cat, X_test_bin, X_test_quant])
# Número de registros en cada conjunto
print("Registros en el conjunto de entrenamiento:", X_train_processed.shape[0])
print("Registros en el conjunto de validación:", X_val_processed.shape[0])
print("Registros en el conjunto de test:", X_test_processed.shape[0])
# Observación:
# Balance de clases en cada conjunto (usando los vectores originales de y)
print("\nBalance de clases en el conjunto de entrenamiento:")
print(y_train.value_counts(normalize=True))
print("\nBalance de clases en el conjunto de validación:")
print(y_val.value_counts(normalize=True))
print("\nBalance de clases en el conjunto de test:")
print(y_test.value_counts(normalize=True))
Registros en el conjunto de entrenamiento: 380935 Registros en el conjunto de validación: 95234 Registros en el conjunto de test: 119043 Balance de clases en el conjunto de entrenamiento: target 0 0.963553 1 0.036447 Name: proportion, dtype: float64 Balance de clases en el conjunto de validación: target 0 0.963553 1 0.036447 Name: proportion, dtype: float64 Balance de clases en el conjunto de test: target 0 0.963551 1 0.036449 Name: proportion, dtype: float64
# Balance de las clasess a predecir por el modelo. XGBoost y LightGBM utilizarán este peso para darle más importancia a los errores en la clase minoritaria.
scale_pos_weight = y_train.value_counts()[0] / y_train.value_counts()[1]
print(scale_pos_weight)
26.4369778161913
Diseño de Modelos. Selección de Hiperparámetros con Optuna.
Una vez tenemos claro el conjunto de datos de entrada a cada modelo en las fases de entrenamiento, validación y test, y tras haber expuesto de forma exhaustiva el espectro de hiperparámetros y sus rangos acotados de valores, es el momento de introducir la selección de hiperparámetros y, en consecuencia, el uso de Optuna.
El proceso nominal de calibración de un algoritmo pasa por buscar la mejor combinación posible de hiperparámetros para nuestro caso de uso concreto. Este paso, lejos de ser trivial, implica enfrentarse a interacciones no lineales entre los parámetros y a la complejidad inherente que ofrece cada modelo mediante sus grados de libertad (hiperparámetros idiosincráticos).
Por ello, el paso previo y de mayor relevancia en cualquier flujo de entrenamiento serio es definir de forma consciente y razonada el subconjunto de hiperparámetros a optimizar para cada solución propuesta. La importancia de este proceso es evidente: impacta directamente en la capacidad predictiva y en la generalización del modelo final.
Un ajuste adecuado nos permite alcanzar una solución robusta y eficiente para el problema, mientras que una mala selección puede derivar en sobreajuste, infraajuste o incluso en una utilización ineficaz tanto de los datos como de los recursos computacionales.
Antes de abordar en detalle la optimización con Optuna, conviene tener una visión estructurada de las principales estrategias existentes para la selección de hiperparámetros en el contexto actual del aprendizaje automático. A continuación, se presenta una clasificación técnica que distingue los distintos enfoques según su naturaleza, desde métodos clásicos hasta técnicas más sofisticadas basadas en modelos probabilísticos o asignación dinámica de recursos.
Justificación de la selección de Optuna como técnica de optimización¶
| Categoría | Método / Framework | Técnica principal | Tipo de búsqueda | Referencias clave |
|---|---|---|---|---|
| Búsqueda clásica | Grid Search | Búsqueda exhaustiva | Determinista | Bergstra & Bengio, 2012 |
| Randomized Search | Muestreo aleatorio | Estocástica | Bergstra & Bengio, 2012 | |
| Optimización bayesiana | Bayesian Optimization | Modelado con Gaussian Process | Adaptativa | Snoek et al., 2012 |
| Hyperopt | Tree-structured Parzen Estimator (TPE) | Adaptativa | Bergstra et al., 2013 | |
| Optuna | TPE + pruning (early stopping) | Adaptativa + eficiente | Akiba et al., 2019 | |
| Asignación de recursos | Hyperband | Sucesivos descartes con early stopping | Basada en rendimiento | Li et al., 2017 |
| BOHB (HpBandSter) | Bayesian Opt. + Hyperband | Adaptativa + recursos | Falkner et al., 2018 | |
| Metaheurísticas evolutivas | Genetic Algorithms | Evolución biológica simulada | Estocástica, heurística | DEAP library |
| TPOT | AutoML basado en programación genética | Heurística + AutoML | Olson & Moore, 2016 |
Si bien existen alternativas muy potentes como BOHB o algoritmos evolutivos, Optuna presenta una combinación especialmente atractiva de eficiencia computacional, flexibilidad y capacidad adaptativa. Gracias a su enfoque basado en TPE, su integración con técnicas de *early stopping* y su diseño modular, se posiciona como una de las soluciones más robustas y modernas para contextos de modelización avanzada con restricciones computacionales. Estas características justifican plenamente su elección como herramienta de optimización en este trabajo.
# Definición de regresión logística. Planteamiento clásico:
def objective_logreg(trial):
params = {
"C": trial.suggest_float("C", 0.01, 10, log=True),
"solver": trial.suggest_categorical("solver", ["lbfgs", "liblinear"]),
"class_weight": "balanced",
"max_iter": 500
}
model = LogisticRegression(**params)
model.fit(X_train_processed, y_train)
y_pred_proba = model.predict_proba(X_val_processed)[:, 1]
return roc_auc_score(y_val, y_pred_proba)
# Definición de XGB:
def objective_xgb(trial):
params = {
"n_estimators": trial.suggest_int("n_estimators", 50, 500),
"max_depth": trial.suggest_int("max_depth", 3, 12),
"learning_rate": trial.suggest_float("learning_rate", 0.005, 0.3, log=True),
"scale_pos_weight": scale_pos_weight, # Ajuste para el desbalanceo
"subsample": trial.suggest_float("subsample", 0.6, 1.0),
"colsample_bytree": trial.suggest_float("colsample_bytree", 0.6, 1.0),
"gamma": trial.suggest_float("gamma", 0, 5),
"reg_alpha": trial.suggest_float("reg_alpha", 0.0, 1.0), # Regularización L1
"reg_lambda": trial.suggest_float("reg_lambda", 0.0, 1.0) # Regularización L2
}
model = XGBClassifier(**params, use_label_encoder=False, eval_metric="auc")
model.fit(
X_train_processed, y_train,
eval_set=[(X_val_processed, y_val)],
verbose=False
)
y_pred_proba = model.predict_proba(X_val_processed)[:, 1]
return roc_auc_score(y_val, y_pred_proba)
# Definición de LigthGBM:
def objective_lgbm(trial):
params = {
"num_leaves": trial.suggest_int("num_leaves", 10, 100),
"learning_rate": trial.suggest_float("learning_rate", 0.005, 0.3, log=True),
"n_estimators": trial.suggest_int("n_estimators", 50, 500),
"is_unbalance": True, # LightGBM ajusta automáticamente la ponderación de clases
"feature_fraction": trial.suggest_float("feature_fraction", 0.6, 1.0),
"lambda_l1": trial.suggest_float("lambda_l1", 0.0, 1.0), # Regularización L1
"lambda_l2": trial.suggest_float("lambda_l2", 0.0, 1.0) # Regularización L2
}
model = LGBMClassifier(**params, objective="binary")
model.fit(
X_train_processed, y_train,
eval_set=[(X_val_processed, y_val)],
eval_metric="auc"
)
y_pred_proba = model.predict_proba(X_val_processed)[:, 1]
return roc_auc_score(y_val, y_pred_proba)
# Definición de Tabnet:
def objective_tabnet(trial):
params = {
"n_d": trial.suggest_int("n_d", 8, 64),
"n_a": trial.suggest_int("n_a", 8, 64),
"n_steps": trial.suggest_int("n_steps", 3, 10),
"gamma": trial.suggest_float("gamma", 1.0, 2.0),
"lambda_sparse": trial.suggest_float("lambda_sparse", 0.001, 0.1),
"momentum": trial.suggest_float("momentum", 0.02, 0.98)
}
model = TabNetClassifier(**params, verbose=0)
model.fit(
X_train_processed, y_train.values,
eval_set=[(X_val_processed, y_val.values)],
eval_metric=['auc'],
patience=50,
max_epochs=1000,
batch_size=256,
virtual_batch_size=64,
drop_last=False
)
y_pred_proba = model.predict_proba(X_val_processed)[:, 1]
return roc_auc_score(y_val, y_pred_proba)
# Versionado y accesibilidad:
version = "tfm_rsin_v00"
version_dir = f"results/{version}"
# Creación del directorio:
os.makedirs(version_dir, exist_ok=True)
# Guardo encoder e imputer para usarlos ex-post (e.g para desplegar entorno productivo):
with open(f"{version_dir}/encoder_{version}.pkl", "wb") as f:
pickle.dump(encoder, f)
with open(f"{version_dir}/imputer_cat_{version}.pkl", "wb") as f:
pickle.dump(imputer_cat, f)
Selección de Hiperparámetros con Optuna:
# EJECUCIÓN DE OPTUNA
# --------------------------------------------------------------------------------------------
# Iniciamos servidor Optuna Dashboard en segundo plano
study_storage = f"sqlite:///{version_dir}/optuna_dashboard_{version}.db"
subprocess.Popen(["optuna-dashboard", study_storage, "--port", "8080"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(3) # Tiempo de espera de unos segundos para que se inicie el servidor
# Función para mostrar Optuna Dashboard en el notebook:
def show_optuna_dashboard():
display(HTML(f'<iframe src="http://localhost:8080" width="100%" height="600px"></iframe>'))
# Estudios:
n_trials = 10
study_logreg = optuna.create_study(direction="maximize", storage=study_storage, study_name=f"logreg_{version}", load_if_exists=True)
study_logreg.optimize(objective_logreg, n_trials=n_trials)
study_xgb = optuna.create_study(direction="maximize", storage=study_storage, study_name=f"xgb_{version}", load_if_exists=True)
study_xgb.optimize(objective_xgb, n_trials=n_trials)
study_lgbm = optuna.create_study(direction="maximize", storage=study_storage, study_name=f"lgbm_{version}", load_if_exists=True)
study_lgbm.optimize(objective_lgbm, n_trials=n_trials)
study_tabnet = optuna.create_study(direction="maximize", storage=study_storage, study_name=f"tabnet_{version}", load_if_exists=True)
study_tabnet.optimize(objective_tabnet, n_trials=n_trials)
# Mostramos el dashboard en local cuando se ejecuta por 1a vez:
show_optuna_dashboard()
# Viz en web cuando ya está definido el proceso:
study_storage = f"sqlite:///{version_dir}/optuna_dashboard_{version}.db"
subprocess.Popen(["optuna-dashboard", study_storage, "--port", "8080"], stdout=subprocess.DEVNULL)
time.sleep(2)
webbrowser.open("http://localhost:8080")
# El True indica que se ha aperturado correctamente en una ventana del navegador
True
Guardado:
# Guardado de hiperparámetros de cada estudio para posterior entrenamiento:
with open(f"{version_dir}/best_params_logreg_{version}.pkl", "wb") as f:
pickle.dump(study_logreg.best_params, f)
with open(f"{version_dir}/best_params_xgb_{version}.pkl", "wb") as f:
pickle.dump(study_xgb.best_params, f)
with open(f"{version_dir}/best_params_lgbm_{version}.pkl", "wb") as f:
pickle.dump(study_lgbm.best_params, f)
with open(f"{version_dir}/best_params_tabnet_{version}.pkl", "wb") as f:
pickle.dump(study_tabnet.best_params, f)
# Guardado de los estudios completos a parte de solo los mejores hiperparms:
with open(f"{version_dir}/study_logreg_{version}.pkl", "wb") as f:
pickle.dump(study_logreg, f)
with open(f"{version_dir}/study_xgb_{version}.pkl", "wb") as f:
pickle.dump(study_xgb, f)
with open(f"{version_dir}/study_lgbm_{version}.pkl", "wb") as f:
pickle.dump(study_lgbm, f)
with open(f"{version_dir}/study_tabnet_{version}.pkl", "wb") as f:
pickle.dump(study_tabnet, f)
5. Entrenamiento y Evaluación
Carga de resultados:
# Llamada al resultado de optuna (estudios):
with open(f"{version_dir}/study_logreg_{version}.pkl", "rb") as f:
study_logreg = pickle.load(f)
with open(f"{version_dir}/study_xgb_{version}.pkl", "rb") as f:
study_xgb = pickle.load(f)
with open(f"{version_dir}/study_lgbm_{version}.pkl", "rb") as f:
study_lgbm = pickle.load(f)
with open(f"{version_dir}/study_tabnet_{version}.pkl", "rb") as f:
study_tabnet = pickle.load(f)
# Viz del proceso de Optuna, sobre las 10 iteraciones definidas:
def plot_study_history(study, name):
values = [trial.value for trial in study.trials if trial.value is not None]
plt.plot(values, label=name)
plt.figure(figsize=(16, 4))
plot_study_history(study_logreg, "Logistic Regression")
plot_study_history(study_xgb, "XGBoost")
plot_study_history(study_lgbm, "LightGBM")
plot_study_history(study_tabnet, "TabNet")
plt.xlabel("Iteración")
plt.ylabel("Score (e.g. AUC)")
plt.title("Historial de puntuaciones durante la optimización")
plt.legend()
plt.grid()
plt.tight_layout()
plt.show()
# Guardado en df de la información del estudio:
resumen = pd.DataFrame({
"Modelo": ["Logistic Regression", "XGBoost", "LightGBM", "TabNet"],
"Mejor Score": [
study_logreg.best_value,
study_xgb.best_value,
study_lgbm.best_value,
study_tabnet.best_value
],
"Mejor Trial": [
study_logreg.best_trial.number,
study_xgb.best_trial.number,
study_lgbm.best_trial.number,
study_tabnet.best_trial.number
],
"Nº de Trials": [
len(study_logreg.trials),
len(study_xgb.trials),
len(study_lgbm.trials),
len(study_tabnet.trials)
]
})
# importancia de hiperparámetros
importancias = {
"Logistic Regression": optuna.importance.get_param_importances(study_logreg),
"XGBoost": optuna.importance.get_param_importances(study_xgb),
"LightGBM": optuna.importance.get_param_importances(study_lgbm),
"TabNet": optuna.importance.get_param_importances(study_tabnet),
}
importances_df = pd.DataFrame.from_dict(importancias, orient="index").T
display(resumen)
| Modelo | Mejor Score | Mejor Trial | Nº de Trials | |
|---|---|---|---|---|
| 0 | Logistic Regression | 0.612074 | 2 | 10 |
| 1 | XGBoost | 0.613174 | 8 | 10 |
| 2 | LightGBM | 0.614129 | 3 | 10 |
| 3 | TabNet | 0.609591 | 4 | 10 |
display(importances_df)
| Logistic Regression | XGBoost | LightGBM | TabNet | |
|---|---|---|---|---|
| C | 0.528875 | NaN | NaN | NaN |
| solver | 0.471125 | NaN | NaN | NaN |
| reg_lambda | NaN | 0.586202 | NaN | NaN |
| learning_rate | NaN | 0.179487 | 0.649074 | NaN |
| subsample | NaN | 0.074384 | NaN | NaN |
| reg_alpha | NaN | 0.055256 | NaN | NaN |
| n_estimators | NaN | 0.051085 | 0.058087 | NaN |
| max_depth | NaN | 0.040157 | NaN | NaN |
| gamma | NaN | 0.008216 | NaN | 0.239190 |
| colsample_bytree | NaN | 0.005212 | NaN | NaN |
| lambda_l1 | NaN | NaN | 0.164544 | NaN |
| num_leaves | NaN | NaN | 0.082291 | NaN |
| lambda_l2 | NaN | NaN | 0.037389 | NaN |
| feature_fraction | NaN | NaN | 0.008615 | NaN |
| lambda_sparse | NaN | NaN | NaN | 0.302038 |
| n_steps | NaN | NaN | NaN | 0.275329 |
| n_a | NaN | NaN | NaN | 0.066660 |
| n_d | NaN | NaN | NaN | 0.064870 |
| momentum | NaN | NaN | NaN | 0.051913 |
Entrenamiento final de Modelos:
# Llamo a los resultados para entrenamiento definitivo de modelos:
with open(f"{version_dir}/best_params_logreg_{version}.pkl", "rb") as f:
params_logreg = pickle.load(f)
with open(f"{version_dir}/best_params_xgb_{version}.pkl", "rb") as f:
params_xgb = pickle.load(f)
with open(f"{version_dir}/best_params_lgbm_{version}.pkl", "rb") as f:
params_lgbm = pickle.load(f)
with open(f"{version_dir}/best_params_tabnet_{version}.pkl", "rb") as f:
params_tabnet = pickle.load(f)
# Diccionarios de guardado de información del entrenamiento:
modelos = {}
curvas = {}
# Comprobación de parametría:
print("LR:", params_logreg)
print("XGB:", params_xgb)
print("LGBM:", params_lgbm)
print("TabNet:", params_tabnet)
LR: {'C': 5.366156675267107, 'solver': 'liblinear'}
XGB: {'n_estimators': 473, 'max_depth': 4, 'learning_rate': 0.005726464156884109, 'subsample': 0.8820362099340904, 'colsample_bytree': 0.6038723206241715, 'gamma': 1.6810668392690635, 'reg_alpha': 0.7331456904581268, 'reg_lambda': 0.5085616047004449}
LGBM: {'num_leaves': 28, 'learning_rate': 0.022649794660349016, 'n_estimators': 277, 'feature_fraction': 0.6000156786962209, 'lambda_l1': 0.5036226962512214, 'lambda_l2': 0.15331867481902328}
TabNet: {'n_d': 58, 'n_a': 64, 'n_steps': 10, 'gamma': 1.0260856760812516, 'lambda_sparse': 0.0030223562078505292, 'momentum': 0.5236943812424828}
Regresión Logística:
# Regresión logística:
params_logreg.update({"class_weight": "balanced", "max_iter": 500})
model_logreg = LogisticRegression(**params_logreg)
model_logreg.fit(X_train_processed, y_train)
modelos["LogisticRegression"] = model_logreg
XGBoost:
# XGBoost
params_xgb.update({"scale_pos_weight": scale_pos_weight})
results_xgb = {}
model_xgb = XGBClassifier(**params_xgb, use_label_encoder=False, eval_metric="auc")
model_xgb.fit(
X_train_processed, y_train,
eval_set=[(X_train_processed, y_train), (X_val_processed, y_val)],
verbose=False
)
results_xgb = model_xgb.evals_result()
modelos["XGBoost"] = model_xgb
curvas["XGBoost"] = results_xgb
c:\Program Files\Python311\Lib\site-packages\xgboost\core.py:158: UserWarning:
[15:35:59] WARNING: C:\buildkite-agent\builds\buildkite-windows-cpu-autoscaling-group-i-0c55ff5f71b100e98-1\xgboost\xgboost-ci-windows\src\learner.cc:740:
Parameters: { "use_label_encoder" } are not used.
LigthGBM:
# LightGBM
params_lgbm.update({"is_unbalance": True})
results_lgbm = {}
model_lgbm = LGBMClassifier(**params_lgbm, objective="binary")
model_lgbm.fit(
X_train_processed, y_train,
eval_set=[(X_train_processed, y_train), (X_val_processed, y_val)],
eval_metric="auc"
)
results_lgbm = model_lgbm.evals_result_
modelos["LightGBM"] = model_lgbm
curvas["LightGBM"] = results_lgbm
[LightGBM] [Warning] feature_fraction is set=0.6000156786962209, colsample_bytree=1.0 will be ignored. Current value: feature_fraction=0.6000156786962209 [LightGBM] [Warning] lambda_l1 is set=0.5036226962512214, reg_alpha=0.0 will be ignored. Current value: lambda_l1=0.5036226962512214 [LightGBM] [Warning] lambda_l2 is set=0.15331867481902328, reg_lambda=0.0 will be ignored. Current value: lambda_l2=0.15331867481902328 [LightGBM] [Warning] feature_fraction is set=0.6000156786962209, colsample_bytree=1.0 will be ignored. Current value: feature_fraction=0.6000156786962209 [LightGBM] [Warning] lambda_l1 is set=0.5036226962512214, reg_alpha=0.0 will be ignored. Current value: lambda_l1=0.5036226962512214 [LightGBM] [Warning] lambda_l2 is set=0.15331867481902328, reg_lambda=0.0 will be ignored. Current value: lambda_l2=0.15331867481902328 [LightGBM] [Info] Number of positive: 13884, number of negative: 367051 [LightGBM] [Info] Auto-choosing row-wise multi-threading, the overhead of testing was 0.016348 seconds. You can set `force_row_wise=true` to remove the overhead. And if memory is not enough, you can set `force_col_wise=true`. [LightGBM] [Info] Total Bins 970 [LightGBM] [Info] Number of data points in the train set: 380935, number of used features: 46 [LightGBM] [Warning] feature_fraction is set=0.6000156786962209, colsample_bytree=1.0 will be ignored. Current value: feature_fraction=0.6000156786962209 [LightGBM] [Warning] lambda_l1 is set=0.5036226962512214, reg_alpha=0.0 will be ignored. Current value: lambda_l1=0.5036226962512214 [LightGBM] [Warning] lambda_l2 is set=0.15331867481902328, reg_lambda=0.0 will be ignored. Current value: lambda_l2=0.15331867481902328 [LightGBM] [Info] [binary:BoostFromScore]: pavg=0.036447 -> initscore=-3.274764 [LightGBM] [Info] Start training from score -3.274764
Tabnet:
# TabNet
model_tabnet = TabNetClassifier(**params_tabnet, verbose=0)
model_tabnet.fit(
X_train_processed, y_train.values,
eval_set=[(X_train_processed, y_train.values), (X_val_processed, y_val.values)],
eval_metric=['auc'],
patience=10,
max_epochs=10,
batch_size=128,
virtual_batch_size=32,
drop_last=False
)
modelos["TabNet"] = model_tabnet
curvas["TabNet"] = {
"train_auc": model_tabnet.history['val_0_auc'],
"valid_auc": model_tabnet.history['val_1_auc']}
Stop training because you reached max_epochs = 10 with best_epoch = 3 and best_val_1_auc = 0.57215
c:\Program Files\Python311\Lib\site-packages\pytorch_tabnet\callbacks.py:172: UserWarning: Best weights from best epoch are automatically used!
# Inspecciono artefacto Curvas:
for modelo, contenido in curvas.items():
print(f"\n Modelo: {modelo}")
if isinstance(contenido, dict):
for clave, valor in contenido.items():
if isinstance(valor, dict):
print(f" Subclave: {clave} --> Keys internas: {list(valor.keys())}")
elif isinstance(valor, list):
print(f" {clave}: Lista de longitud {len(valor)} (valores tipo: {type(valor[0])})")
else:
print(f" {clave}: {type(valor)}")
else:
print(f" Contenido directo: {type(contenido)}")
Modelo: XGBoost Subclave: validation_0 --> Keys internas: ['auc'] Subclave: validation_1 --> Keys internas: ['auc'] Modelo: LightGBM Subclave: training --> Keys internas: ['auc', 'binary_logloss'] Subclave: valid_1 --> Keys internas: ['auc', 'binary_logloss'] Modelo: TabNet train_auc: Lista de longitud 10 (valores tipo: <class 'numpy.float64'>) valid_auc: Lista de longitud 10 (valores tipo: <class 'numpy.float64'>)
# Inspecciono artefacto Modelos:
for modelo, contenido in modelos.items():
print(f"\nModelo: {modelo}")
if isinstance(contenido, dict):
for clave, valor in contenido.items():
if isinstance(valor, dict):
print(f" Subclave: {clave} --> Keys internas: {list(valor.keys())}")
elif isinstance(valor, list):
print(f" {clave}: Lista de longitud {len(valor)} (valores tipo: {type(valor[0])})")
else:
print(f" {clave}: {type(valor)}")
else:
print(f" Contenido directo: {type(contenido)}")
Modelo: LogisticRegression Contenido directo: <class 'sklearn.linear_model._logistic.LogisticRegression'> Modelo: XGBoost Contenido directo: <class 'xgboost.sklearn.XGBClassifier'> Modelo: LightGBM Contenido directo: <class 'lightgbm.sklearn.LGBMClassifier'> Modelo: TabNet Contenido directo: <class 'pytorch_tabnet.tab_model.TabNetClassifier'>
# Visualización de las curvas de validación de los modelos basados en ensemble (más de 200 iteraciones de entrenamiento):
plt.figure(figsize=(12, 7))
plt.title("Curvas AUC de Validación por Modelo", fontsize=16)
plt.xlabel("Iteración / Época", fontsize=12)
plt.ylabel("AUC en Validación", fontsize=12)
plt.grid(True, linestyle='--', alpha=0.5)
colores = {
"XGBoost": "#1f77b4",
"LightGBM": "#17becf",
"TabNet": "#08306b"
}
for modelo in curvas:
if modelo == "XGBoost":
aucs = curvas[modelo]["validation_1"]["auc"]
elif modelo == "LightGBM":
aucs = curvas[modelo]["valid_1"]["auc"]
else:
continue
x = list(range(len(aucs)))
color = colores.get(modelo, "blue")
plt.plot(x, aucs, label=f"{modelo}", linewidth=2.5, color=color)
max_idx = int(np.argmax(aucs))
max_val = aucs[max_idx]
plt.scatter(max_idx, max_val, color=color, edgecolor='white', zorder=5, s=80)
plt.legend(title="Modelos", fontsize=11, title_fontsize=12, loc="lower right")
plt.tight_layout()
plt.show()
Evaluación sobre el conjunto de test:
# Evaluación de las métricas sobre el conjunto de test:
metricas = {}
for nombre, modelo in modelos.items():
try:
if nombre == "TabNet":
y_prob = modelo.predict_proba(X_test_processed)[:, 1]
y_pred = modelo.predict(X_test_processed).ravel()
else:
y_prob = modelo.predict_proba(X_test_processed)[:, 1]
y_pred = modelo.predict(X_test_processed)
metricas[nombre] = {
"ROC-AUC": roc_auc_score(y_test, y_prob),
"PR-AUC": average_precision_score(y_test, y_prob),
"Recall": recall_score(y_test, y_pred),
"F1-score": f1_score(y_test, y_pred),
"Precision": precision_score(y_test, y_pred),
"Accuracy": accuracy_score(y_test, y_pred)
}
except Exception as e:
print(f"Error al evaluar {nombre}: {e}")
df_metricas = pd.DataFrame(metricas).T.sort_values(by="ROC-AUC", ascending=False).round(4)
print("\n Métricas en el conjunto de test:")
df_metricas
[LightGBM] [Warning] feature_fraction is set=0.6000156786962209, colsample_bytree=1.0 will be ignored. Current value: feature_fraction=0.6000156786962209 [LightGBM] [Warning] lambda_l1 is set=0.5036226962512214, reg_alpha=0.0 will be ignored. Current value: lambda_l1=0.5036226962512214 [LightGBM] [Warning] lambda_l2 is set=0.15331867481902328, reg_lambda=0.0 will be ignored. Current value: lambda_l2=0.15331867481902328 [LightGBM] [Warning] feature_fraction is set=0.6000156786962209, colsample_bytree=1.0 will be ignored. Current value: feature_fraction=0.6000156786962209 [LightGBM] [Warning] lambda_l1 is set=0.5036226962512214, reg_alpha=0.0 will be ignored. Current value: lambda_l1=0.5036226962512214 [LightGBM] [Warning] lambda_l2 is set=0.15331867481902328, reg_lambda=0.0 will be ignored. Current value: lambda_l2=0.15331867481902328 Métricas en el conjunto de test:
| ROC-AUC | PR-AUC | Recall | F1-score | Precision | Accuracy | |
|---|---|---|---|---|---|---|
| XGBoost | 0.6117 | 0.0568 | 0.5545 | 0.0914 | 0.0498 | 0.5979 |
| LogisticRegression | 0.6114 | 0.0555 | 0.5469 | 0.0920 | 0.0502 | 0.6065 |
| LightGBM | 0.6109 | 0.0566 | 0.5582 | 0.0928 | 0.0506 | 0.6023 |
| TabNet | 0.5841 | 0.0487 | 0.0002 | 0.0005 | 0.1429 | 0.9635 |
# GUARDADO DE MODELOS
for nombre, modelo in modelos.items():
with open(f"{version_dir}/modelo_trained_{nombre}_{version}.pkl", "wb") as f:
pickle.dump(modelo, f)
print(f"Modelos guardados correctamente en '{version_dir}' con sufijo de versión.")
Modelos guardados correctamente en 'results/tfm_rsin_v00' con sufijo de versión.
Consideraciones de la evaluación - Mejor modelo:
Nuestro target indica la ocurrencia de un siniestro, cuya correcta detección es crítica para una gestión eficiente del riesgo asegurador en la compañía. Su criticidad es elevada y su eventualidad, remota (evento altamente desbalanceado, ~4%), lo cual ha sido contrastado durante todo el análisis empírico.
Modelos basados en ensemble de árboles (XGBoost / LightGBM)¶
Ambos modelos presentan un rendimiento competitivo en términos de capacidad discriminante, alcanzando valores de ROC-AUC superiores a 0.61. Este resultado sugiere una adecuada separación entre las clases a lo largo del conjunto completo de umbrales posibles, lo cual es relevante en contextos donde el umbral óptimo no está previamente definido. Adicionalmente, se observa un valor de PR-AUC por encima de 0.056, que debe contextualizarse cuidadosamente: dado que la tasa base de siniestros en el conjunto de datos es ≈ 4%, el rendimiento de un clasificador aleatorio oscilaría en torno a ese valor. Por tanto, superar significativamente esta cota implica que el modelo es capaz de identificar con cierta eficacia la clase positiva, incluso en presencia de un desequilibrio extremo. Podemos fijarnos adicionalmente en las matrices que derivan de la matriz de confusión, en un umbral fijado, si bien esto se desarrollará a posteriori para el mdjor modelo... El comportamiento de las métricas recall** y **F1-score resulta especialmente destacable. El recall moderado asegura una baja tasa de falsos negativos, lo cual es fundamental en problemas de riesgo donde omitir un evento crítico (como un siniestro) puede tener consecuencias operativas y económicas graves. Asimismo, el F1-score consistente denota una estabilidad en la predicción de la clase positiva, sin incurrir en una pérdida desproporcionada de precisión. En conjunto, estos resultados refuerzan la idoneidad de los modelos basados en árboles de decisión para esta problemática, al ofrecer una sensibilidad adecuada sin comprometer la estabilidad general del modelo.
Asimismo, cabe destacar que su estructura aditiva de árboles de decisión, es capaz de modelar interacciones no lineales, inherentes a entornos regulatorios y datos aseguradores.
Regresión Logística¶
A pesar de ser el benchmark tradicional en problemas de riesgo por su trazabilidad y facilidad de interpretación, su marco lineal puede limitar la detección de patrones no triviales, especialmente en presencia de múltiples variables categóricas complejas. Su performance es más que adecuado, y muy cercano a las consideraciones que se han desprendido de los ensembles, detacando como sigue siendo una solución factible y eficaz ante planteamientos de medición del riesgo.
TabNet¶
A pesar de su capacidad teórica en representación jerárquica, en este problema TabNet ofrece un accuracy artificialmente alto (96.35%) con un recall prácticamente nulo, revelando que simplemente predice la clase mayoritaria. Si bien en sus métricas agregadas de diferenciación, se observa una menor capacidad de distinción de clases ROC-AUC y PR-AUC, elementos principales los los que se descartaría a nivel objetivo el modelo aunque, podemos ir más allá en este caso...
Ni su estructura atencional ni sus capacidades adaptativas parecen aportar mejoras en este problema, incluso tras calibración de batch_size, patience o virtual_batch_size. Su PR-AUC < 0.05 y su muy elevado costo computacional del proceso de entrenamiento refuerzan las voluntades de desconsideración de este modelo de estado del arte.
Decisión final: árboles de decisión ensamblados (boosting)¶
Dado el análisis anterior, se concluye que el modelo óptimo es XGBoost. A diferencia de TabNet o la regresión logística, además este modelo garantiza:
- Estabilidad entre ejecuciones con hiperparámetros estables.
- Robustez ante variables categóricas codificadas, sin pérdidas de rendimiento.
- Capacidad de trazabilidad gracias a SHAP y Partial Dependence Plots.
Mejor modelo (criterio, max auc):
mejor_modelo_nombre = df_metricas.index[0]
mejor_modelo = modelos[mejor_modelo_nombre]
print(f"\n Mejor modelo en términos relativos al resto: {mejor_modelo_nombre}")
Mejor modelo en términos relativos al resto: XGBoost
Diagnosis de la matriz de confusión del mejor modelo seleccionado:
Visualización de una matriz de Confusión Teórica sobre un caso de uso binario:¶
| Real \ Predicho | 0 (Predicho) | 1 (Predicho) |
|---|---|---|
| 0 (Real) | TN | FP |
| 1 (Real) | FN | TP |
Insights Relevantes para nuestro problema¶
El análisis de la matriz de confusión para distintos cutoffpuede revelar patrones clave que orienten la toma de decisiones en la configuración final del modelo de predicción de siniestralidad. Como se ha citado con anterioridad, esto nos puede acercar más/menos a nuestro negocio, pero mantendremos el poder discriminante global del modelo. Por esa razón, en este paartado visualizaremos comos e comporta el clasificador del XGBoost en función de los distintos cutt-off de 0 a 1.
Es muy importante este proceso, ya que el buen entendimiento de la matriz de confusión pasa por entender la lógica de un clasificador. [AQUI degradación de la matriz]
- En base a eso, dotamos de un objetivo principal: maximizar los TP (True Positives), es decir, detectar correctamente los siniestros reales. En términos métricos, esto se traduce en maximizar el
recally minimizar los FN (False Negatives), ya que fallar en esta clase implica no activar mecanismos de cobertura o prevención donde realmente corresponde. - La
accuracysigue siendo una métrica relevante desde el punto de vista del rendimiento global. Por ello, se busca un equilibrio en el que:- La proporción de TN (True Negatives) se mantenga alta, para no degradar la fiabilidad general del sistema.
- El volumen de FP (False Positives) no crezca de forma descontrolada, lo cual perjudicaría la
precisiony podría acarrear consecuencias económicas por falsas alarmas.
Este equilibrio entre sensitividad y especificidad es esencial en entornos donde la clase positiva es escasa pero crítica, como es el caso del riesgo de siniestralidad.
# Predicciones de probabilidad:
y_prob_mejor = mejor_modelo.predict_proba(X_test_processed)[:, 1]
# Cutoffs a evaluar:
cutoffs = np.round(np.arange(0.0, 1.0, 0.1), 2)
# viz:
fig, axs = plt.subplots(2, 5, figsize=(22, 10))
axs = axs.flatten()
for i, cutoff in enumerate(cutoffs):
y_pred_cutoff = (y_prob_mejor >= cutoff).astype(int)
cm = confusion_matrix(y_test, y_pred_cutoff)
disp = ConfusionMatrixDisplay(confusion_matrix=cm)
disp.plot(ax=axs[i], cmap='Blues', colorbar=False)
axs[i].set_title(f"Cutoff = {cutoff}", fontsize=12)
axs[i].grid(False)
plt.suptitle(f"Matrices de Confusión del Modelo Óptimo: {mejor_modelo_nombre}", fontsize=18, y=1.02)
plt.tight_layout()
plt.show()
Relevancia en la visualización de la matriz de conf. en distintos cut-off's:
La visualización de las distintas matrices de confusión "raw", en función del valor del cut-off, nos muestran como un valor de dicho umbral >= 0.4 e < 0.5 sería el adecuad pues mantiene: TN vs FP elevados y TP vs FN elevados, respectivamente.
La designación del mismo pasa por criterios puros de negocio. E.g el considerar un umbral igual a 0.4 implica una correcta detección de los siniestros (recall elevado), pero una penalización sobre casos sin siniestro asumiendo que estos tienen... (casos FP del cuadrante superior derecho). Asimismo, el considerar un umbral de 0.5, implica en penalizar a muchos menos usaurios que no disponen de siniestro (menor valor de FP), que son degradados de manera natural desde bucket FP hacia bucket TN, sim embargo, implica una reducción considerable del recall, debido a la degradación de casos TP a FN en los cuadrantes inferiores.
En este punto, ya intervienen las distintas políticas de negocio de la compañía, y/o las fases en las que se busque identificar dicha siniestralidad. Por ejemplo, sobre una primera preselección de casos potenciales a siniestro, un umbral de 0.45 sería adecuado.
A continuación, todas estas consideraciones, se plasman en formato curva, con el plotting de los cuadrantes de la matriz en función del cutt-off, así como de las diferentes métricas que se derivan de estos.
cutoffs = np.round(np.arange(0.0, 1.0, 0.1), 2)
tns, fps, fns, tps = [], [], [], []
# Cálculo de la matriz de confusión para cada cutoff
for cutoff in cutoffs:
y_pred_cutoff = (y_prob_mejor >= cutoff).astype(int)
tn, fp, fn, tp = confusion_matrix(y_test, y_pred_cutoff).ravel()
tns.append(tn)
fps.append(fp)
fns.append(fn)
tps.append(tp)
fig, ax1 = plt.subplots(figsize=(12, 6))
# Eje para TN y FP:
ax1.plot(cutoffs, tns, marker='o', label="True Negatives (TN)")
ax1.plot(cutoffs, fps, marker='o', label="False Positives (FP)")
ax1.set_xlabel("Cutoff", fontsize=12)
ax1.set_ylabel("TN / FP", fontsize=12)
ax1.tick_params(axis='y')
ax1.grid(True, linestyle='--', alpha=0.5)
# Eje para FN y TP:
ax2 = ax1.twinx()
ax2.plot(cutoffs, fns, 'g--o', label="False Negatives (FN)")
ax2.plot(cutoffs, tps, 'r--o', label="True Positives (TP)")
ax2.set_ylabel("FN / TP", fontsize=12)
ax2.tick_params(axis='y')
# Unión de ambos ejes
lines_1, labels_1 = ax1.get_legend_handles_labels()
lines_2, labels_2 = ax2.get_legend_handles_labels()
ax1.legend(lines_1 + lines_2, labels_1 + labels_2, loc='center right')
plt.title(f"Evolución de la Matriz de Confusión - {mejor_modelo_nombre}", fontsize=16)
plt.xticks(cutoffs)
plt.tight_layout()
plt.show()
# Cutoffs a evaluar
cutoffs = np.round(np.arange(0.0, 1.0, 0.1), 2)
precisions, recalls, f1s, accuracies = [], [], [], []
for cutoff in cutoffs:
y_pred_cutoff = (y_prob_mejor >= cutoff).astype(int)
precisions.append(precision_score(y_test, y_pred_cutoff, zero_division=0))
recalls.append(recall_score(y_test, y_pred_cutoff, zero_division=0))
f1s.append(f1_score(y_test, y_pred_cutoff, zero_division=0))
accuracies.append(accuracy_score(y_test, y_pred_cutoff))
plt.figure(figsize=(12, 6))
plt.plot(cutoffs, precisions, marker='o', label="Precision")
plt.plot(cutoffs, recalls, marker='o', label="Recall")
plt.plot(cutoffs, f1s, marker='o', label="F1-score")
plt.plot(cutoffs, accuracies, marker='o', label="Accuracy")
plt.title(f"Evolución de Métricas - {mejor_modelo_nombre}", fontsize=16)
plt.xlabel("Cutoff", fontsize=12)
plt.ylabel("Valor de la Métrica", fontsize=12)
plt.xticks(cutoffs)
plt.grid(True, linestyle='--', alpha=0.5)
plt.ylim(0, 1.05)
plt.legend()
plt.tight_layout()
plt.show()
Consideraciones finales:
Limitaciones de Modelado Supervisado en Entornos de Alto Desbalance
Los resultados obtenidos tras la implementación y evaluación exhaustiva de modelos supervisados —Logistic Regression, XGBoost, LightGBM y TabNet— revelan una capacidad limitada para discriminar adecuadamente a los clientes siniestrados en contextos fuertemente desbalanceados.
A pesar de haber incorporado técnicas de compensación estándar como la ponderación de clases (class_weight="balanced"), el ajuste explícito de scale_pos_weight en modelos de boosting, y el uso de funciones de pérdida adaptadas, el rendimiento sobre la clase minoritaria ha permanecido moderado.
En otras palabras, no es el modelo el que falla, sino el conjunto de información que este puede explotar.
El análisis realizado confirma que el empleo de algoritmos sofisticados no garantiza, por sí solo, un rendimiento adecuado cuando el problema de base posee una señal débil o está contaminado por ruido irreducible.
Además, en dominios regulados como el financiero, donde la interpretabilidad y la estabilidad son tan importantes como la precisión, estas limitaciones no son únicamente estadísticas, sino también operativas.
Por tanto, se concluye que la mejora del rendimiento predictivo en contextos de riesgo de siniestralidad no depende exclusivamente de la elección del modelo, sino de la ampliación y enriquecimiento del espacio de variables predictoras.
Por ello, se recomienda, para futuros casos de uso:
- El desarrollo de variables derivadas mediante análisis de series temporales de comportamiento financiero.
- La incorporación de información contextual, macroeconómica o derivada de redes relacionales entre clientes.
- El empleo de técnicas de representación densa (e.g., embeddings) o aprendizaje contrastivo para capturar estructuras latentes.
- La exploración de modelos causales que permitan desambiguar correlaciones espurias de relaciones estructurales.
El presente trabajo muestra que el rigor metodológico en la validación y ajuste de modelos no siempre conlleva una mejora sustancial cuando las limitaciones son intrínsecas al sistema de información, y sienta las bases para una línea de investigación centrada en la mejora del valor informativo más allá de la arquitectura del modelo.
A pesar de obtener múltiples modelos, el recall y la precisión siguen siendo bajos, lo que refuerza la hipótesis de que la limitación es estructural y no técnica.
Del siniestro al "riesgo de siniestro": Calibración del espacio de probabilidad
En la predicción del riesgo de siniestralidad, la calibración del modelo resulta fundamental cuando se requiere que las probabilidades predichas sean interpretadas directamente como frecuencias observables. A continuación se expone una motivación basada en escenarios reales del dominio asegurador.
Ejemplo aplicado: riesgo asegurador
Supóngase que un modelo predice una probabilidad de siniestro del 0.60 para un individuo. No obstante, al analizar empíricamente dicho segmento, se constata que la proporción real de siniestros es del 10%. En este caso, el modelo está sobrestimando el riesgo, lo cual puede inducir decisiones erróneas desde una perspectiva operativa o de negocio.
Caso 1: Pricing de seguros
Cuando se utiliza directamente la probabilidad predicha como entrada del sistema de tarificación:
- Se podría asignar una prima excesiva a clientes con riesgo moderado, comprometiendo la competitividad.
- Se puede infravalorar el coste real en clientes verdaderamente riesgosos, afectando la rentabilidad técnica.
Caso 2: Selección de cartera o underwriting
En procesos donde se establece un umbral de aceptación (cutoff), una mala calibración puede provocar:
- Rechazo de buenos clientes debido a sobrestimación del riesgo.
- Aceptación de clientes de alto riesgo si se subestima la probabilidad.
Cuando se selecciona el top 5% de mayor riesgo para una auditoría o intervención, se debe verificar que:
- El orden del score sea adecuado (discriminación: ROC-AUC).
- La proporción predicha coincida con la observada, especialmente si se estiman pérdidas agregadas.
Conclusión técnica
La calibración no mejora la discriminación del modelo, pero alinea las probabilidades con la frecuencia empírica. Esto es crucial cuando:
- Se requiere consistencia y trazabilidad del sistema.
- Se estiman pérdidas esperadas como:
E[Pérdida] = P(Siniestro) × Severidad
Se debe implementar calibración con métodos como regresión isotónica o Platt scaling en los casos en los que las probabilidades del modelo sean utilizadas como decisiones probabilísticas reales.
La calibración es una herramienta esencial cuando la probabilidad predicha tiene una interpretación directa en decisiones operativas, y su correcta implementación eleva la fiabilidad del sistema de toma de decisiones.
Isotonic regresion
La regresión isotónica es una técnica no paramétrica utilizada para modelar una función monótona creciente a partir de datos empíricos. Su aplicación más relevante en problemas de clasificación binaria es la calibración de probabilidades, ajustando la salida de un modelo predictivo (score) para que refleje correctamente la probabilidad empírica de ocurrencia del evento positivo.
Sea un conjunto de pares ordenados ((s_i, y_i)), donde (s_i) es el score generado por un modelo (por ejemplo, la salida de XGBoost antes de aplicar el umbral) y (y_i entre {0,1}) la clase real observada. La regresión isotónica busca una función monótona creciente (f) que minimice el error cuadrático medio entre la predicción calibrada y la clase real:
$$ \min_{f \in \mathcal{M}} \sum_{i=1}^n (f(s_i) - y_i)^2 \\ \text{donde } \mathcal{M} = \{ f \text{ monótona creciente} \} $$
Este ajuste se realiza típicamente mediante el algoritmo de Pool Adjacent Violators (PAV), que encuentra la mejor aproximación por tramos constantes no decrecientes. Esta técnica es no paramétrica, lo que la hace especialmente flexible frente a distribuciones sesgadas o altamente desbalanceadas, como ocurre en riesgo de siniestralidad (nuestro caso de uso). Además, preserva la ordenación relativa, manteniendo su utilidad como ranking.
- No impone forma funcional sobre la relación entre el score y la probabilidad real: ideal en problemas donde esta relación puede ser piecewise o no sigmoidea.
- Permite una interpretación probabilística más fiel, especialmente en regiones donde el modelo original tiende a sobreestimar o infraestimar riesgos.
- Su naturaleza no paramétrica implica mayor flexibilidad, aunque también mayor riesgo de sobreajuste si el conjunto de calibración es pequeño o poco representativo.
En el ámbito asegurador, es común encontrar modelos que generan scores adecuados desde un punto de vista de ordenación (ROC-AUC), pero que fallan en traducir estos scores a probabilidades calibradas. En estos casos, isotonic regression actúa como una capa post-hoc que transforma los scores en una escala probabilística más realista, sin modificar el modelo original.
Este método constituye una herramienta potente cuando se requiere interpretar la salida del modelo como una probabilidad fiable. Su uso es especialmente relevante en entornos regulados o de alta criticidad (como seguros o crédito), donde las decisiones no solo deben ser correctas, sino cuantitativamente justificables.
Aplicación sobre XGboost
El modelo XGBoost, al igual que otros algoritmos basados en gradient boosting, tiende a producir valores extremos (muy cercanos a 0 o 1) en sus predicciones probabilísticas, lo que implica una sobreconfianza en sus decisiones. A pesar de obtener buenos valores de ROC-AUC, su output no refleja necesariamente probabilidades empíricas bien calibradas.
Por tanto, es razonable aplicar un método de calibración post-hoc que mapea los scores del modelo a probabilidades reales observadas. Una solución robusta y ampliamente aceptada es el uso del wrapper de `scikit-learn` CalibratedClassifierCV, que permite calibrar cualquier estimador compatible con el método predict_proba, como es el caso de `XGBClassifier`.
Este wrapper permite calibrar el modelo en una estrategia de validación cruzada o en un conjunto de validación separado, ajustando una función de calibración como en este caso isotonic. La calibración se realiza sin reentrenar completamente el modelo base, simplemente ajustando una capa superior de transformación probabilística.
Así, se conserva el poder predictivo del modelo original, pero con una salida más alineada con la frecuencia real de ocurrencia del evento positivo, lo cual es crítico para tareas de toma de decisión reguladas o sensibles. Asimismo:
- Permite mantener la estructura y parámetros del modelo XGBoost entrenado.
- No requiere modificaciones al preprocesamiento ni al pipeline ya entrenado.
- Mejora la interpretabilidad y confiabilidad de la probabilidad como score final.
Es importante destacar que CalibratedClassifierCV requiere que el modelo no esté previamente calibrado internamente (como podría ocurrir si se aplicara un entrenamiento con log_loss y regularización extrema). Por ello, lo empleamos tras entrenar el modelo con una métrica de ordenación como auc, como se ha hecho en el presente caso de uso.
# Calibrador con regresión isotónica y clase CalibratedClassifierCV:
calibrador = CalibratedClassifierCV(
estimator=mejor_modelo,
method='isotonic',
cv=StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
)
# Entrenamiento sobre conjunto de entrenamiento:
calibrador.fit(X_train_processed, y_train)
# Probabilidades sobre test:
y_prob_original = mejor_modelo.predict_proba(X_test_processed)[:, 1]
y_prob_calibrado = calibrador.predict_proba(X_test_processed)[:, 1]
c:\Program Files\Python311\Lib\site-packages\xgboost\core.py:158: UserWarning:
[17:22:13] WARNING: C:\buildkite-agent\builds\buildkite-windows-cpu-autoscaling-group-i-0c55ff5f71b100e98-1\xgboost\xgboost-ci-windows\src\learner.cc:740:
Parameters: { "use_label_encoder" } are not used.
c:\Program Files\Python311\Lib\site-packages\xgboost\core.py:158: UserWarning:
[17:22:20] WARNING: C:\buildkite-agent\builds\buildkite-windows-cpu-autoscaling-group-i-0c55ff5f71b100e98-1\xgboost\xgboost-ci-windows\src\learner.cc:740:
Parameters: { "use_label_encoder" } are not used.
c:\Program Files\Python311\Lib\site-packages\xgboost\core.py:158: UserWarning:
[17:22:28] WARNING: C:\buildkite-agent\builds\buildkite-windows-cpu-autoscaling-group-i-0c55ff5f71b100e98-1\xgboost\xgboost-ci-windows\src\learner.cc:740:
Parameters: { "use_label_encoder" } are not used.
c:\Program Files\Python311\Lib\site-packages\xgboost\core.py:158: UserWarning:
[17:22:35] WARNING: C:\buildkite-agent\builds\buildkite-windows-cpu-autoscaling-group-i-0c55ff5f71b100e98-1\xgboost\xgboost-ci-windows\src\learner.cc:740:
Parameters: { "use_label_encoder" } are not used.
c:\Program Files\Python311\Lib\site-packages\xgboost\core.py:158: UserWarning:
[17:22:42] WARNING: C:\buildkite-agent\builds\buildkite-windows-cpu-autoscaling-group-i-0c55ff5f71b100e98-1\xgboost\xgboost-ci-windows\src\learner.cc:740:
Parameters: { "use_label_encoder" } are not used.
# Calibración sobre 10 bins:
prob_true_orig, prob_pred_orig = calibration_curve(y_test, y_prob_original, n_bins=10, strategy='quantile')
prob_true_cal, prob_pred_cal = calibration_curve(y_test, y_prob_calibrado, n_bins=10, strategy='quantile')
fig = plt.figure(figsize=(16, 10))
gs = fig.add_gridspec(2, 1, height_ratios=[2.5, 1], hspace=0.3)
# Curva:
ax0 = fig.add_subplot(gs[0])
ax0.plot([0, 1], [0, 1], 'k--', label='Calibración perfecta')
ax0.plot(prob_pred_orig, prob_true_orig, 'o--', label='Original', color='steelblue')
ax0.plot(prob_pred_cal, prob_true_cal, 'o-', label='Calibrado (Isotonic)', color='darkorange')
ax0.set_title(f"Curva de Calibración - {mejor_modelo_nombre}", fontsize=14)
ax0.set_xlabel("Probabilidad predicha")
ax0.set_ylabel("Proporción real de positivos")
ax0.set_xlim([0, 1])
ax0.set_ylim([0, 1])
ax0.grid(True, linestyle='--', alpha=0.6)
ax0.legend(loc="upper left")
ax1 = fig.add_subplot(gs[1])
sns.histplot(y_prob_original, bins=20, label='Original', color='steelblue', alpha=0.5)
sns.histplot(y_prob_calibrado, bins=20, label='Calibrado', color='darkorange', alpha=0.5)
ax1.set_xlabel("Probabilidad predicha")
ax1.set_ylabel("Frecuencia")
ax1.set_xlim([0, 1])
ax1.legend()
ax1.grid(True, linestyle='--', alpha=0.4)
plt.tight_layout()
plt.show()
C:\Users\U0124476\AppData\Local\Temp\1\ipykernel_32804\2234182741.py:30: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect.
# Brier Score como métrica para conocer que se ha calibrado:
brier_original = brier_score_loss(y_test, y_prob_original)
brier_calibrated = brier_score_loss(y_test, y_prob_calibrado)
print(f"Brier Score (original): {brier_original:.4f}")
print(f"Brier Score (calibrado): {brier_calibrated:.4f}")
Brier Score (original): 0.2389 Brier Score (calibrado): 0.0349
Resultados de la calibración:
- Antes de calibrar, el modelo sobreestimaba la probabilidad de siniestro. La curva de calibración azul se encontraba por debajo de la diagonal, reflejando dicha sobreconfianza.
- Después de calibrar con regresión isotónica, las probabilidades se distribuyen en [0, 0.1] y la curva naranja se aproxima mucho más a la diagonal ideal. Esto mejora la correspondencia entre probabilidad predicha y frecuencia observada.
El Brier Score mide la media del error cuadrático entre las probabilidades predichas y las etiquetas reales (0 o 1). Cuanto más bajo, mejor calibrado está el modelo.
Ejemplo visual del efecto de la calibración sobre un cliente (ID) con siniesto y sin siniestro:
y_test_array = np.array(y_test)
idx_y0 = np.where(y_test_array == 0)[0][0] # Primer ejemplo clase 0
idx_y1 = np.where(y_test_array == 1)[0][0] # Primer ejemplo clase 1
# Probabilidades predichas para ambos casos:
prob_orig_y0 = mejor_modelo.predict_proba(X_test_processed[idx_y0:idx_y0+1])[0, 1]
prob_calib_y0 = calibrador.predict_proba(X_test_processed[idx_y0:idx_y0+1])[0, 1]
prob_orig_y1 = mejor_modelo.predict_proba(X_test_processed[idx_y1:idx_y1+1])[0, 1]
prob_calib_y1 = calibrador.predict_proba(X_test_processed[idx_y1:idx_y1+1])[0, 1]
print(f"-Cliente sin siniestro (y=0) — riesgo original: {prob_orig_y0:.4f}, calibrado: {prob_calib_y0:.4f}")
print(f"-Cliente con siniestro (y=1) — riesgo original: {prob_orig_y1:.4f}, calibrado: {prob_calib_y1:.4f}")
-Cliente sin siniestro (y=0) — riesgo original: 0.4792, calibrado: 0.0345 -Cliente con siniestro (y=1) — riesgo original: 0.3544, calibrado: 0.0162
Este resultado es coherente con el comportamiento esperado de la calibración en escenarios como el nuestro (en este caso, ≈4% de siniestros), por:
- El modelo no calibrado tiende a sobreestimar el riesgo global, asignando probabilidades infladas incluso a clientes que no presentan siniestros.
- La regresión isotónica ajusta la escala probabilística para que los valores predichos reflejen las frecuencias reales observadas durante la validación cruzada.
- Al haber pocos casos positivos, la calibración corrige la distribución bajando agresivamente las probabilidades para evitar falsas alarmas, especialmente en probabilidades mal separadas.
Este ajuste no altera el ranking relativo de las predicciones, pero sí mejora la confianza probabilística en términos absolutos, aspecto fundamental para tareas como scoring, pricing o toma de decisiones reguladas.
En este contexto, la calibración no mejora la discriminación entre clases, pero aporta interpretabilidad probabilística al modelo, asegurando que "riesgo 30%" significa verdaderamente "30% de clientes similares han tenido siniestros".
Por tanto, este análisis empírico reafirma la necesidad de calibrar modelos cuando se busca interpretar probabilidades predichas de forma confiable. La diferencia entre predicción ordinal (ranking) y probabilística (calibración) es crítica en sistemas reales.
6. Explicabilidad
Interpretabilidad y Técnicas de XAI en Contextos Regulatorios
En contextos complejos o regulados como es el presente, la interpretabilidad del modelo es tan importante como su rendimiento. Por esa razón, es imprescindible incorporar técnicas de explicabilidad —dentro del ámbito denominado explainable AI (XAI)— que permitan comprender el porqué de las predicciones, evaluar posibles sesgos y generar confianza en modelos complejos (como los de tipo ensemble o deep learning).
El estado del arte en explicabilidad de modelos destaca principalmente dos enfoques: SHAP (SHapley Additive exPlanations) y LIME (Local Interpretable Model-agnostic Explanations). Ambos comparten el objetivo de interpretar modelos que suelen visualizarse como una "caja negra", pero lo hacen desde perspectivas distintas.
LIME: explicaciones locales por aproximación
- Enfoque: Perturba la instancia a explicar generando muestras similares y ajusta un modelo interpretable (usualmente lineal) en su vecindario.
- Modelo-agnóstico: Se puede aplicar a cualquier tipo de modelo.
- Interpretación: Genera explicaciones locales, válidas solo cerca del punto evaluado.
- Ventaja: Rápido y fácil de implementar.
- Limitación: Las explicaciones pueden ser inestables, sensibles al muestreo aleatorio.
Ejemplo conceptual:
Dado un cliente rechazado por el modelo de crédito, LIME genera versiones ligeramente modificadas del cliente (cambiando algunos atributos), observa cómo cambia la predicción y ajusta un modelo lineal local para explicar ese resultado.
SHAP: teoría de juegos aplicada a la explicabilidad
- Fundamento matemático: Basado en valores de Shapley (teoría de juegos cooperativos), asigna a cada variable una contribución justa al resultado.
- Additividad: Las contribuciones suman la diferencia entre la predicción individual y la media del modelo.
- Local y global: Permite interpretaciones tanto por instancia como agregadas.
- Consistencia: Si una variable gana relevancia, su valor SHAP no disminuye.
- Limitación: Costo computacional elevado, especialmente para modelos no optimizados.
Implementaciones eficientes en Python:
TreeExplainer: para XGBoost, LightGBM o CatBoost — muy eficiente.KernelExplainer: para modelos arbitrarios — más general, pero más lento.
Comparativa técnica: SHAP vs LIME
| Característica | LIME | SHAP |
|---|---|---|
| Fundamento teórico | Aproximación local con modelo interpretable | Teoría de juegos (valores de Shapley) |
| Tipo de explicabilidad | Local | Local y global |
| Modelo-agnóstico | Sí | Sí (más eficiente en árboles) |
| Estabilidad | Dependiente del muestreo | Alta estabilidad |
| Costo computacional | Bajo | Medio - Alto |
| Coherencia teórica | No garantiza consistencia | Asegura propiedades deseables |
| Visualizaciones | Básicas | Avanzadas: beeswarm, waterfall, force plots |
Explicabilidad local vs global
Explicabilidad Local
- Objetivo: Entender por qué el modelo ha hecho una predicción concreta para una instancia específica.
- Nivel de análisis: Individual.
- Aplicación típica: ¿Por qué se ha denegado el crédito a este cliente? ¿Qué variables influyeron en su score?
- Métodos típicos:
- LIME
- SHAP (instancia a instancia:
explainer.shap_values(X[i])) - Explicaciones contrafactuales (Counterfactual explanations)
Ejemplo:
Un cliente obtiene un score de 0.87 en un modelo de riesgo como nuestro caso de uso. La explicación local descompone esta predicción como una suma de contribuciones de cada variable para ese cliente en concreto.
Explicabilidad Global
- Objetivo: Comprender el comportamiento general del modelo sobre todo el dataset.
- Nivel de análisis: Agregado.
- Aplicación típica: ¿Qué variables son más importantes en general? ¿Cómo afecta
un featureen concreto a las predicciones? - Métodos típicos:
- SHAP (global:
summary_plot,beeswarm) - Importancia de variables tradicional (gain, split)
- Partial Dependence Plots (PDP)
- Permutation Importance
- SHAP (global:
Ejemplo:
Sobre un conjunto de n clientes, se observa que las variables que más influyen globalmente en el resultado del modelo de riesgo sonX,YyZ.
Consideración:
- La explicabilidad local ayuda a interpretar casos individuales, cruciales sobre decisiones automatizadas que afectan a un cliente/id en particular, por ejemplo, en la aplicación individual del modelo a la hora de evaluar la entrada de un cliente potencial en la aseguradora.
- La explicabilidad global proporciona una visión agregada del modelo, útil para entender su lógica interna general, detectar sesgos sistemáticos y validar consistencia.
# DataFrame con nombres de columnas (lo necesitamos para LIME y SHAP):
# Obtenemos nombres de columnas del One-Hot Encoding aplicado:
cat_columns_encoded = encoder.get_feature_names_out(categorical_vars)
# Concatenamos todos los nombres (en orden: categóricas, binarias, cuantitativas)
column_names = np.concatenate([cat_columns_encoded, binary_vars, continuous_vars])
# Creamos el DataFrame con nombres reales
X_test_df = pd.DataFrame(X_test_processed, columns=column_names)
print("Columnas reales recuperadas:")
print(X_test_df.columns[:10])
print(X_test_df)
Columnas reales recuperadas:
Index(['ps_car_02_cat_-1.0', 'ps_car_02_cat_0.0', 'ps_car_02_cat_1.0',
'ps_car_03_cat_-1.0', 'ps_car_03_cat_0.0', 'ps_car_03_cat_1.0',
'ps_car_07_cat_-1.0', 'ps_car_07_cat_0.0', 'ps_car_07_cat_1.0',
'ps_car_04_cat_0.0'],
dtype='object')
ps_car_02_cat_-1.0 ps_car_02_cat_0.0 ps_car_02_cat_1.0 \
0 0.0 0.0 1.0
1 0.0 0.0 1.0
2 0.0 1.0 0.0
3 0.0 0.0 1.0
4 0.0 1.0 0.0
... ... ... ...
119038 0.0 0.0 1.0
119039 0.0 0.0 1.0
119040 0.0 0.0 1.0
119041 0.0 0.0 1.0
119042 0.0 0.0 1.0
ps_car_03_cat_-1.0 ps_car_03_cat_0.0 ps_car_03_cat_1.0 \
0 1.0 0.0 0.0
1 1.0 0.0 0.0
2 1.0 0.0 0.0
3 1.0 0.0 0.0
4 0.0 0.0 1.0
... ... ... ...
119038 1.0 0.0 0.0
119039 1.0 0.0 0.0
119040 1.0 0.0 0.0
119041 1.0 0.0 0.0
119042 1.0 0.0 0.0
ps_car_07_cat_-1.0 ps_car_07_cat_0.0 ps_car_07_cat_1.0 \
0 0.0 0.0 1.0
1 0.0 0.0 1.0
2 0.0 0.0 1.0
3 0.0 0.0 1.0
4 0.0 0.0 1.0
... ... ... ...
119038 0.0 0.0 1.0
119039 0.0 0.0 1.0
119040 0.0 0.0 1.0
119041 0.0 0.0 1.0
119042 0.0 0.0 1.0
ps_car_04_cat_0.0 ... ps_reg_02_1.8 ps_ind_06_bin ps_ind_07_bin \
0 1.0 ... 0.0 0.0 0.0
1 1.0 ... 0.0 1.0 0.0
2 0.0 ... 0.0 1.0 0.0
3 1.0 ... 0.0 1.0 0.0
4 1.0 ... 0.0 0.0 1.0
... ... ... ... ... ...
119038 0.0 ... 0.0 0.0 0.0
119039 1.0 ... 0.0 0.0 0.0
119040 1.0 ... 0.0 0.0 1.0
119041 1.0 ... 0.0 1.0 0.0
119042 1.0 ... 0.0 0.0 0.0
ps_ind_17_bin ps_ind_16_bin ps_car_08_cat ps_car_13 ps_car_12 \
0 0.0 1.0 1.0 -0.305634 0.404717
1 0.0 0.0 0.0 0.925573 0.404717
2 0.0 0.0 1.0 0.737699 1.331018
3 0.0 0.0 1.0 -0.568187 -0.868016
4 1.0 0.0 1.0 1.298715 0.880888
... ... ... ... ... ...
119038 0.0 1.0 1.0 0.980217 1.331018
119039 0.0 1.0 0.0 0.185329 -0.868016
119040 0.0 0.0 0.0 0.378881 -0.868016
119041 0.0 1.0 1.0 0.111294 0.404717
119042 0.0 1.0 1.0 -1.718987 1.625274
ps_reg_03 ps_car_14
0 0.658043 0.695908
1 0.436240 0.349483
2 1.272016 1.401177
3 1.284480 0.132113
4 0.010037 0.218773
... ... ...
119038 1.588775 1.164890
119039 -1.040090 -0.167640
119040 1.087000 -0.248427
119041 -0.548643 0.593940
119042 1.480619 1.247362
[119043 rows x 47 columns]
LIME
# Explicabilidad local con Lime sobre una observación:
# Selección del primer ID (cliente) del dataset por trazabilidad (evitar la marcación con randint, si bien esta sería de la siguiente manera):
# i = np.random.randint(0, len(X_test_processed))
i = 0
explainer_lime = lime.lime_tabular.LimeTabularExplainer(
training_data=X_test_processed,
feature_names=X_test_df.columns.tolist(),
class_names=['No', 'Sí'],
mode='classification'
)
exp = explainer_lime.explain_instance(
data_row=X_test_processed[i],
predict_fn=mejor_modelo.predict_proba
)
exp.show_in_notebook(show_table=True)
Análisis de una predicción individual con LIME
LIME genera un modelo lineal interpretable localmente ajustado a una vecindad perturbada del punto a explicar, en este caso el primer registro del dataset.
Probabilidades de predicción:
- La probabilidad predicha es 0.48 para clase positiva (Sí) y 0.52 para clase negativa (No). El modelo, por tanto, se sitúa en una región que podríamos designar de elevada incertidumbre, muy próxima al umbral de decisión estándar (0.5), lo que refleja un caso de frontera estadística.
- El modelo ha sido influenciado por múltiples variables con contribuciones marginales pequeñas, lo que sugiere que ninguna característica domina claramente la decisión local, sino que esta se sustenta en una acumulación de señales débiles. Los features más marcados son:
🔵 ps_ind_17_bin = 0 → contribuye hacia la clase No:
Esto significa que, cuando esta variable toma valor 0, la predicción final se empuja hacia la clase 0 (no siniestro). Desde una perspectiva estadística, en el entrenamiento el valor `0` de esta variable probablemente esté asociado con una menor probabilidad de siniestralidad.
Por tanto, en este caso, su valor actúa como "protector" frente al riesgo.
🟠 ps_reg_02_0.1 = 0 → contribuye hacia la clase Sí:
La variable transformada `ps_reg_02_0.1 = 0` puede haber resultado de un one-hot encoding sobre una variable categórica o binned continua. Su valor `0` indica ausencia de esta categoría. Sin embargo, en este contexto específico, su ausencia **se asocia a un mayor riesgo**, lo que significa que otras categorías diferentes a la 0.1 presentan más siniestralidad.
La observación en particular, presenta muchas variables con contribuciones pequeñas en direcciones opuestas. Esto implica que no hay un único factor dominante, sino que el modelo construye la probabilidad de forma acumulativa a partir de señales débiles. Esto es típico en modelos tabulares de riesgo donde la señal es difusa y "multicausal"
- Los valores individuales de cada variable pueden favorecer o penalizar la predicción de siniestralidad, dependiendo de su asociación aprendida durante el entrenamiento
- En LIME, un valor puede contribuir a la clase positiva o negativa dependiendo de su dirección estimada localmente. Es importante entender que esta contribución no es global, sino específica de la instancia analizada.
- Este tipo de explicaciones son útiles para auditoría individual de decisiones, sobre todo en casos de frontera (como este: 52% vs 48%).
El comportamiento del modelo en este punto refleja una región de baja separabilidad del espacio de representación. Esto puede deberse a una señal estructural débil o a una configuración de variables que no permite una separación lineal o no lineal clara.
El uso de LIME ha permitido validar que, para esta instancia, la predicción es débilmente soportada por el modelo, lo que podría tener implicaciones en la confianza operativa de la decisión si esta instancia es crítica (ej. cliente de alto valor económico).
SHAP
Explicabilidad Local
# Muestreo para X test:
X_sample = X_test_df.sample(100, random_state=42)
# SHAP es caro computacionalmente, por lo que visualizamos su carácter global sobre una muestra aleatoria de 100 obs.
# Wrapper de predicción
def predict_fn(X):
return mejor_modelo.predict_proba(X)[:, 1]
# Explainer y obtención de SHAP Values:
explainer = shap.Explainer(predict_fn, X_sample)
shap_values = explainer(X_sample)
# Explicabilidad local sobre el primero:
shap.plots.waterfall(shap_values[0])
PermutationExplainer explainer: 101it [00:18, 3.74it/s]
Análisis local de explicabilidad – SHAP¶
La predicción realizada por el modelo XGBoost para una observación concreta se explica mediante la descomposición aditiva proporcionada por SHAP (SHapley Additive exPlanations). En este caso, el valor predicho es:
$$ f(x) = 0.497 $$
partiendo de un valor esperado global (base rate del modelo):
$$ \mathbb{E}[f(X)] = 0.485 $$
Esto implica que la combinación específica de variables para este individuo ha generado un ligero incremento en el riesgo estimado de siniestralidad, con una contribución neta total de:
$$ \sum_{i=1}^n \phi_i = +0.012 $$
donde $$\phi_i$$ representa el valor SHAP de la variable $$i$$.
Elementos clave del gráfico
- Valor base: representa la expectativa del modelo antes de ver los datos (media de predicciones).
- Barras rojas: variables que aumentan la predicción (acercan al resultado positivo).
- Barras azules: variables que reducen la predicción (acercan al resultado negativo).
Contribuciones individuales más relevantes¶
ps_car_13 = 1.154→ contribuye +0.04 al riesgo predicho. Es el efecto dominante. Este valor (por encima de la media poblacional) se interpreta como un marcador de riesgo elevado, posiblemente por su relación con características del vehículo o asegurado que aumentan la siniestralidad.ps_ind_16_bin = 1→ contribuye −0.01, lo que sugiere que esta condición binaria se asocia con mayor estabilidad o menor riesgo.ps_ind_06_bin = 0→ tiene un efecto positivo de +0.01, por lo que su ausencia indica cierta debilidad o mayor exposición al riesgo.ps_reg_03 = −0.354→ con contribución −0.01, este valor reducido de una variable regional puede indicar un contexto geográfico de baja siniestralidad.- Otras variables aportan efectos marginales, configurando un patrón de agregación de microefectos que es típico en problemas de señal débil con múltiples features categóricas.
Ventajas observadas
- Transparencia total sobre el porqué de una predicción específica.
- Permite contrastar y auditar decisiones de modelos complejos con respaldo matemático sólido (valores de Shapley).
- Es particularmente útil en observaciones "fronterizas" o inesperadas, donde se requiere justificación precisa.
Interpretación formal¶
El modelo actúa como una función aditiva sobre la base media, mediante:
$$ f(x) = \mathbb{E}[f(X)] + \sum_{i=1}^n \phi_i $$
Esta propiedad asegura interpretabilidad y coherencia local. En este caso, la predicción final de 0.497 refleja una combinación equilibrada de señales débiles, sin dominancia extrema de ninguna variable salvo ps_car_13. La predicción se sitúa muy cerca del umbral de decisión 0.5, lo que confirma la incertidumbre del modelo sobre este individuo concreto.
Explicabilidad Global
shap.plots.beeswarm(shap_values)
shap.plots.bar(shap_values)
Explicabilidad global mediante SHAP¶
Se ha llevado a cabo un análisis de explicabilidad global utilizando valores de SHAP (SHapley Additive exPlanations) sobre una muestra aleatoria de 100 observaciones del conjunto de test. Este enfoque permite evaluar cómo cada variable contribuye, en promedio, a las decisiones del modelo en toda la muestra, siguiendo una descomposición aditiva:
$$ f(x) = \phi_0 + \sum_{j=1}^M \phi_j $$
donde (phi_j) es el valor SHAP asociado a la variable (j), e indica la contribución de dicha variable al desplazamiento de la predicción con respecto al valor base (phi_0 = {E}[f(X)]), que representa la media de salida del modelo.
Gráficos interpretativos¶
Bar Plot (SHAP Summary): Muestra el impacto medio absoluto $|\phi_j|$ de cada variable sobre la predicción. Variables como
ps_car_13,ps_reg_03ops_ind_17_binson las más influyentes, lo que revela su poder discriminativo global.Beeswarm Plot: Complementa lo anterior mostrando la dispersión de los valores SHAP por variable, junto con una codificación de color que representa el valor real de la feature. Este tipo de gráfico permite visualizar interacciones complejas y posibles efectos no lineales.
Consideraciones matemáticas¶
- La importancia de una variable está dada por ({E}[|phi_j|]), que cuantifica su efecto medio en la predicción.
- Los valores SHAP son coherentes con la teoría de juegos (valor de Shapley), garantizando equidad en la atribución de contribuciones incluso en presencia de correlación entre variables.
- La suma de todos los SHAP de una observación más el valor base devuelve exactamente la probabilidad estimada por el modelo, validando la descomposición.
Conclusiones¶
- Variables como
ps_car_13yps_reg_03presentan impactos estructurales relevantes y estables en el output del modelo. - La variabilidad observada en algunas features sugiere posibles interacciones latentes o efectos de subgrupos, lo cual motiva análisis futuros.
# Obtengo una lista de las top n = 5:
mean_abs_shap = np.abs(shap_values.values).mean(axis=0)
top_indices = np.argsort(mean_abs_shap)[::-1][:5]
top_vars = X_sample.columns[top_indices].tolist()
print("top_vars = [")
for var in top_vars:
print(f" '{var}',")
print("]")
top_vars = [
'ps_car_13',
'ps_reg_03',
'ps_ind_17_bin',
'ps_ind_06_bin',
'ps_ind_16_bin',
]
# PDP sobre las 5 variables de SHAP global:
columnas_disponibles = [col for col in top_vars if col in X_test_df.columns]
n_features = len(columnas_disponibles)
n_cols = 5
n_rows = math.ceil(n_features / n_cols)
fig, ax = plt.subplots(n_rows, n_cols, figsize=(n_cols * 5, n_rows * 4))
display = PartialDependenceDisplay.from_estimator(
mejor_modelo,
X_test_processed,
features=columnas_disponibles,
feature_names=X_test_df.columns,
kind='average',
ax=ax.ravel().tolist()
)
fig.suptitle("Partial Dependence Plots - Top SHAP Features", fontsize=18)
plt.tight_layout(rect=[0, 0, 1, 0.96])
plt.show()
Análisis de Partial Dependence Plots (PDP) — Top 5 Features SHAP
Los Partial Dependence Plots (PDPs) permiten visualizar el efecto marginal de una variable sobre la predicción del modelo, manteniendo constantes todas las demás. Se ha aplicado este análisis a las 5 variables más influyentes según SHAP, sobre una muestra aleatoria del conjunto de test.
Interpretaciones individuales
- ps_car_13: presenta una relación positiva clara. A medida que su valor aumenta, también lo hace la probabilidad de siniestro. El efecto es casi monótono, lo que sugiere una correlación directa con el riesgo.
- ps_reg_03: muestra una pendiente suave y creciente. Su efecto marginal no es tan pronunciado, pero podría tener relevancia combinada con otras variables.
- ps_ind_17_bin: variable binaria que impacta positivamente en la predicción. Pasar de 0 a 1 implica un aumento en la probabilidad de siniestralidad.
- ps_ind_06_bin: variable binaria con efecto negativo. Cuando toma el valor 1, reduce la salida del modelo, asociándose con menor riesgo.
- ps_ind_16_bin: comportamiento similar a la anterior. Su activación reduce la probabilidad predicha, lo que podría interpretarse como un indicador de estabilidad o buen perfil.
Consideraciones matemáticas
- Los PDPs complementan a los valores SHAP al mostrar el impacto funcional directo de cada variable.
- Permiten detectar efectos no lineales y umbrales críticos para ciertas features.
- Cuando se observan relaciones suaves y estables, se refuerza la robustez del modelo y su interpretabilidad.
7. Producción (basis)
Despliegue y operacionalización del modelo¶
Una vez desarrollado y validado el modelo predictivo de siniestralidad, se debe plantear su puesta en producción bajo un marco que garantice robustez, trazabilidad, interpretabilidad y mantenibilidad. En entornos reales —especialmente aquellos sujetos a regulación como el financiero o asegurador—, no basta con un buen rendimiento en validación cruzada: es necesario garantizar que el modelo sea auditable y reproducible, y que se comporte adecuadamente ante nuevos datos no vistos.
Para ello, se propone una arquitectura modular basada en microservicios y buenas prácticas de ingeniería de machine learning (MLOps), que separa explícitamente el núcleo predictivo (modelo + preprocesamiento) del API de exposición y del sistema de monitoreo.
Puesta en producción del modelo
Aunque el modelo desarrollado no será desplegado en un entorno productivo real, se ha diseñado una arquitectura modular que simula todos los componentes necesarios para su integración en un pipeline industrializado. Este diseño permite demostrar la comprensión del ciclo completo de implementación de modelos de machine learning en producción.
Estructura general del sistema
ml_scoring_api/
├── config.py
│ # Contiene configuraciones generales del entorno (dev, prod, test), paths de modelos, logging, flags, etc...
│
├── scoring_pipeline.py
│ # Función principal de predicción, reutiliza todo el preprocesamiento realizado en el entrenamiento:
│ import [...]
│
│ # Carga de objetos persistidos
│ modelo_final = joblib.load("model/modelo_XGBoost.pkl")
│ encoder = joblib.load("model/encoder.pkl")
│ imputer_cat = joblib.load("model/imputer_cat.pkl")
│ from variables import categorical_vars, binary_vars, continuous_vars
│
│ def predecir(input_df):
│ [... DETALLE TEÓRICO ABAJO]
│
├── api/
│ ├── main.py
│ │ # API REST con FastAPI. Expone un endpoint /predict que recibe JSON y devuelve predicción
│ │ from fastapi import FastAPI
│ │ from pydantic import BaseModel
│ │ import pandas as pd
│ │ from scoring_pipeline import predecir
│ │
│ │ app = FastAPI()
│ │
│ │ class InputData(BaseModel):
│ │ features: dict
│ │
│ │ @app.post("/predict")
│ │ def predict(data: InputData):
│ │ df = pd.DataFrame([data.features])
│ │ proba = predecir(df)
│ │ return {"probabilidad": float(proba[0])}
│ │
│ └── schemas.py
│ # Validación de entrada con Pydantic
│ from pydantic import BaseModel
│ from typing import Dict
│
│ class InputSchema(BaseModel):
│ features: Dict[str, float]
│
├── streamlit_dashboard/
│ └── app.py
│ # Interfaz visual con Streamlit que permita la subida de un CSV o introducir valores manuales para pred.
│ import streamlit as st
│ import pandas as pd
│ from scoring_pipeline import predecir
│
│ st.title("Scoring de Riesgo de Siniestralidad")
│ uploaded_file = st.file_uploader("Sube un archivo CSV")
│
│ if uploaded_file:
│ df = pd.read_csv(uploaded_file)
│ proba = predecir(df)
│ st.write("Probabilidad de siniestro:", proba)
│
├── model/
│ ├── modelo_XGBoost.pkl
│ ├── encoder.pkl
│ └── imputer_cat.pkl
│
├── variables.py
│ # Variables usadas en todo el pipeline: categóricas, binarias, continuas
│ categorical_vars = [...]
│ binary_vars = [...]
│ continuous_vars = [...]
│
├── Dockerfile
│ # Imagen contenedora del API para correr en producción
│ # FROM python:3.11
│ # COPY . /app
│ # WORKDIR /app
│ # RUN pip install -r requirements.txt
│ # CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8080"]
│
├── requirements.txt
│ # Lista de librerías necesarias para reproducir el entorno
│ # xgboost, pandas, numpy, fastapi, streamlit, joblib, uvicorn
│
└── README.md
# Instrucciones de despliegue, endpoints disponibles, uso en Streamlit y arquitectura general
# scoring_pipeline.py
def predecir(input_df, modelo_final, encoder, imputer_cat, binary_vars, continuous_vars, categorical_vars):
"""
Aplica el preprocesamiento y devuelve la probabilidad de siniestralidad.
Parameters:
- input_df: DataFrame de entrada (1 o varios registros).
- modelo_final: Modelo entrenado (XGBoost).
- encoder: OneHotEncoder fitted.
- imputer_cat: Imputador categórico fitted.
- binary_vars, continuous_vars, categorical_vars: listas de variables según tipo.
Returns:
- np.array: Probabilidades predichas.
"""
# Validación de columnas mínimas necesarias
columnas_requeridas = binary_vars + continuous_vars + categorical_vars
missing_cols = [col for col in columnas_requeridas if col not in input_df.columns]
if missing_cols:
raise ValueError(f"Faltan columnas en el input: {missing_cols}")
# Imputación y transformación
X_cat = imputer_cat.transform(input_df[categorical_vars])
X_cat_encoded = encoder.transform(X_cat)
X_bin = input_df[binary_vars].values
X_quant = input_df[continuous_vars].values
# Concatenar todas las variables preprocesadas
X_processed = np.hstack([X_cat_encoded, X_bin, X_quant])
# Predicción
probas = modelo_final.predict_proba(X_processed)[:, 1]
return probas
Descripción de cada componente
- config.py: archivo centralizado con parámetros de configuración, como rutas, flags de entorno (
dev,prod), configuración de logs o hiperparámetros por defecto. - scoring_pipeline.py: núcleo del sistema. Implementa el pipeline de inferencia, que carga los objetos entrenados (
XGBoost, encoder, imputador) y realiza el mismo preprocesamiento que en fase de entrenamiento. Funciona como punto único para ejecutarpredict_proba. - variables.py: define las listas de variables categóricas, binarias y continuas usadas en la ingeniería de características, garantizando consistencia entre entrenamiento e inferencia.
- api/main.py: expone una API RESTful basada en FastAPI. Define un endpoint
/predictque acepta datos en formato JSON, los transforma en DataFrame, y devuelve una predicción de probabilidad. - api/schemas.py: define los esquemas de validación de entrada y salida usando
Pydantic. Garantiza que los datos recibidos por la API tengan el tipo y formato correcto, aumentando robustez. - streamlit_dashboard/app.py: interfaz visual construida con
Streamlit, orientada a usuarios no técnicos. Permite cargar un archivo CSV o introducir datos manualmente y visualizar en pantalla la probabilidad de siniestro. - model/: carpeta con los artefactos persistidos tras el entrenamiento: el modelo final (
modelo_XGBoost.pkl), el encoder de variables categóricas y el imputador de missing values. - Dockerfile: archivo de contenedor que permite empaquetar todo el sistema para ejecución portable y reproducible. Ideal para entornos cloud o CI/CD. Utiliza
Uvicorncomo servidor de aplicaciones para FastAPI. - requirements.txt: contiene todas las dependencias necesarias del sistema. Incluye librerías como
scikit-learn,fastapi,streamlit,joblib,xgboost, etc. - README.md: archivo de documentación donde se incluyen instrucciones de instalación, endpoints disponibles, ejemplos de uso y estructura de carpetas. Es el punto de entrada para cualquier usuario que quiera reproducir el sistema.
Justificación técnica
El diseño modular permite desacoplar claramente el modelo predictivo de los mecanismos de entrada/salida (API y dashboard). Esta arquitectura permite escalar fácilmente el sistema, mantener trazabilidad sobre las versiones del modelo, aplicar técnicas de calibración post-entrenamiento (como isotonic regression), y facilitar el uso desde distintos frontends (script, API, interfaz gráfica).
Además, se asegura que los datos pasados al modelo en producción sean transformados de forma idéntica a como fueron tratados en el entrenamiento, evitando fugas de información o errores de formato.
Relacióncon todo trabajo realizado
- El modelo de
XGBoostse guarda en formato.pkly se expone mediante un pipeline reproducible, permitiendo predecir nuevos datos de forma consistente. - La estructura del dashboard está pensada para facilitar la explicabilidad y la revisión manual por parte del equipo de negocio.
- La calibración de probabilidades con
CalibratedClassifierCVse integra directamente en el pipeline, permitiendo servir probabilidades ajustadas. - La API y Streamlit actúan como interfaces de consumo interno (back-office) y externo
# pip install notebook
!jupyter nbconvert --to html tesis_001-CXB0010513-v01.ipynb